# Sunshine Design System: specification

Version 1.0. Last updated 2026-10-16.
Published at https://studentdirectory.app/developer/design. Design tokens: https://studentdirectory.app/developer/design/tokens.json. PDF: https://studentdirectory.app/developer/design/sunshine-design-system.pdf.

## 1. Purpose and design principles

### 1.1 Purpose

Flux Student Directory is a school directory for parents and school staff. People open it for quick, practical reasons (who is in my child's class, what is for lunch, is there school tomorrow) usually on a phone, often in a hurry. The visual system exists to make those lookups fast, make different kinds of information easy to tell apart, and make the product feel friendly and trustworthy to a parent.

### 1.2 Principles

1. **One bright color per area.** Each area of the app (Notices, Calendar, Lunch Menus, and so on) has a single color used by its sidebar icon, its count pill, its primary button, and its selection states. Color is how a parent learns where they are.
2. **Color labels, it never carries meaning alone.** Every color is paired with text, an icon, or a shape. A parent who cannot distinguish red from green must still be able to use every screen.
3. **Native first.** On Apple platforms the system provides the type, the materials, the navigation chrome and the light and dark behavior. The Sunshine layer only adds color and shape. It never fights the platform.
4. **Show what we have, hide what we do not.** UI that depends on optional data (a category, a location, a photo) appears only when the data is present. There are no empty placeholders for data a school never entered.
5. **Warm, not loud.** Colors are bright but the surfaces are calm: generous padding, large rounded shapes, soft tints, short text.
6. **Schools can brand it a little.** A school's theme can tint navigation chrome, selection and prominent buttons. It never recolors the Sunshine labelling colors (see section 4.8).
7. **Accessible by default.** WCAG 2.2 AA is the target for web, and Apple's Human Interface Guidelines accessibility guidance for native. See section 12.
8. **The same thing looks the same everywhere.** A category, a grade or a meal type has one color on every screen and every platform.

## 2. Scope and platform matrix

### 2.1 Surfaces

| Surface | Technology | Audience | Sunshine status |
| --- | --- | --- | --- |
| iPhone | SwiftUI | Parents, staff, admins | Full |
| iPad | SwiftUI (same target, split view) | Parents, staff, admins | Full |
| Mac | SwiftUI (same codebase, macOS 26.5+) | Parents, staff, admins | Full |
| Apple Watch | SwiftUI (watchOS 26.5+) | Parents | **Not applied.** The watch app is not restyled with the Sunshine layer yet (section 15.4) |
| App plane web | Next.js 16, Tailwind v4 | Parents, admins | Foundation applied; Sunshine layer applied to categories, filter chips and pills only |

### 2.2 Platform capability matrix

| Capability | iOS | iPadOS | macOS | watchOS | Web (app) |
| --- | --- | --- | --- | --- | --- |
| Sunshine colored sidebar and count pills | Yes | Yes | Yes | No | Not applicable (see 15.5) |
| Category pills and filter chips | Yes | Yes | Yes | No | Yes |
| Filled SF Symbols (`symbolVariant(.fill)`) | Yes | Yes | Yes | Limited | Lucide icons |
| Dynamic Type | Yes | Yes | Yes (system text styles) | Yes | Browser zoom and `rem` units |
| Light and dark | System | System | System | System | `prefers-color-scheme` only |
| Biometric lock | Yes | Yes | No | No | No |
| School theme (tint) | Yes | Yes | Yes | No | Yes |
| Photo editing | Yes | Yes | Yes | No | Yes |

## 3. Brand voice and tone

Friendly, optimistic, plain. A school directory should sound like a helpful front-office person, not a bank.

### 3.1 Voice rules

| Rule | Do | Do not |
| --- | --- | --- |
| Plain words | "No notices right now." | "There are no notices to display at this time." |
| Say what happened and what to do | "Couldn't load categories. Check your connection and reopen this form." | "Error 500." |
| Name things the way parents do | "Lunch Menus", "Notices", "Sign-Ups", "Staff Directory" | Internal names ("audiences", "memberships") |
| Keep labels short | "Send Email", "Open Full PDF Document", "Add to Apple Calendar" | Sentences on buttons |
| Sentence case for body, Title Case for navigation and buttons | "Permission Slips" | "PERMISSION SLIPS" except where a style below says uppercase |
| No blame | "Unlock canceled. Try again, or sign out to use a different account." | "You failed authentication." |
| Neutral family language | "Parent", "Guardian" | Gendered relationship assumptions (the Contact Export feature deliberately uses neutral labels) |

### 3.2 Casing exceptions

- The High Priority banner is all caps: `HIGH PRIORITY NOTICE` on native and web. All caps on purpose, and "Notice" rather than "Notification" so the product term is the same everywhere.
- Detail section labels are uppercased small text: `DOCUMENT PREVIEW`, `EVENT DETAILS`, staff and guardian `Email` / `Phone` section labels (`.uppercased()` in code).
- Everything else is Title Case for navigation and buttons, sentence case for descriptive text.

### 3.3 Things the product does not say

- **No meal times.** The Lunch Menu never shows times; schools differ and the system does not collect them.
- **No "Issued by".** Notices do not show an author line.
- No designation of a "Primary Guardian".
- No "Home Address" label; it is just "Address".

## 4. Color

### 4.1 Model

Color in the system has four layers, from most to least stable:

1. **Semantic text and surface colors** from the platform (primary, secondary, background, grouped background). Never overridden.
2. **Flux brand foundation**: the accent (Flux blue) and the supporting warm colors (sun, coral, leaf).
3. **Sunshine area colors**: one system color per area of the app.
4. **Content colors**: category colors chosen by a school admin from a fixed palette, and derived colors (grade, meal type, calendar category).

An optional **school theme** (section 4.8) tints a small set of chrome elements on top of layers 1 and 2.

### 4.2 Flux brand foundation

`app/app/globals.css`, `SwiftUI/flux-student-directory/Assets.xcassets/`.

| Token | Light | Dark | Role | Text-safe on light canvas |
| --- | --- | --- | --- | --- |
| `flux-blue` (`AccentColor` in SwiftUI) | `#2E7FCB` | `#74B4EF` | Primary actions, links, focus, tint | No: 4.06:1 on `#FFFBF3`. Use `flux-blue-text` for small text |
| `sun` (`SunColor`) | `#FFB020` | `#FFC856` | Highlights, fills, icons | Never as text (1.77:1) |
| `coral` (`CoralColor`) | `#FF6B4A` | `#FF8A6E` | Warm emphasis | Large text and fills only (2.73:1) |
| `leaf` (`LeafColor`) | `#3FA66B` | `#6FCB93` | Success | Large text and fills only (2.96:1) |
| `warning` | `#D97706` | `#FBBF24` | Status, warnings | See contrast notes |
| `danger` | `#DC2626` | `#F87171` | Destructive, errors | AA on white |
| `flux-blue-text` | `#1D6BB0` | = `flux-blue` | Small blue text | 5.38:1 |
| `coral-text` | `#C43F1F` | = `coral` | Small warm text | 4.99:1 |
| `leaf-text` | `#2C7A4E` | = `leaf` | Small success text | 5.09:1 |
| `sun-text` | `#8A5A00` | = `sun` | Small highlight text | 5.74:1 |

The three colors are used for their foundation roles on SwiftUI, glyph-only (never as text): `SunColor` for the star on a starred student's roster row (highlight), `CoralColor` for the warm "not linked to a student" glyph on the guardian detail (warm emphasis), `LeafColor` for success: the "Added to your calendar" check, the "Saved" indicators and the invite-confirmation check.

### 4.3 Neutral and surface colors

**Web, app plane:**

| Token | Light | Dark | Use |
| --- | --- | --- | --- |
| `--background` | `#FFFBF3` (warm paper) | `#1E1A2E` (plum charcoal) | Page canvas |
| `--surface` | `#FFFFFF` | `#272238` | Cards, inputs, header |
| `--foreground` | `#2B2013` | `#F5EEDE` | Body text |
| `--foreground-muted` | `#6B5E4D` | `#C9BFAE` | Secondary text |
| `--border` | `#F0E4D0` | `#3A3350` | Hairlines |

| Token | Light | Dark |
| --- | --- | --- |
| `--background` | `#FAFAFC` | `#0D0F14` |
| `--foreground` | `#171717` | `#EDEDED` |

**SwiftUI:** no custom neutrals. The app uses `.primary`, `.secondary`, `.tertiary`, `.background`, `.background.secondary`, and `Color(.secondarySystemGroupedBackground)`, so Increase Contrast, dark mode and future OS palette changes come for free.

### 4.4 Sunshine area colors

`FluxArea` in `FluxStyle.swift`. Each is a SwiftUI system color, so it adapts to dark mode automatically. The sidebar icon, the count pill, the primary action button on that area's screens, and its selection states all use the same color.

| Area | Color | SF Symbol in sidebar | Notes |
| --- | --- | --- | --- |
| Starred | yellow | `star.circle.fill` |  |
| Lunch Menus | green | `fork.knife` |  |
| Calendar | blue | `calendar` |  |
| Directory Map | teal | `map` |  |
| Notices | red | `megaphone` | Same hue as the Urgent treatment |
| Polls | pink | `chart.bar` |  |
| Permission Slips | mint | `checklist` |  |
| Documents | indigo | `doc.text` |  |
| Staff Directory | orange | `person.text.rectangle` | Keeps orange |
| Sign-Ups | cyan | `hand.raised` |  |
| Students ("Whole School") and Grades | purple | `person.3` |  |
| My Lists | brown | (list symbol) |  |
| Organizations | rust | per-category symbol | Rust, a brown-tinged orange: `#B7410E` light, `#E0764A` dark. Palette key `rust` |
| Classes | viridian | `rectangle.stack.person.crop` | Custom color (`ViridianColor` asset; web `--cat-viridian`): `#40826D` light, `#5FB89B` dark. The 13th palette key. White on the light fill is 4.53:1 |
| Departments | phthalo green | `building.2` | Deep emerald (`PhthaloColor` asset; web `--cat-phthalo`): `#005C32` light, `#17A058` dark. The 15th palette key. Chosen to be clearly different from viridian and from system green (distances in 4.5) |
| School Settings | gray | `house.and.flag.fill` |  |
| Access | indigo | `person.badge.key.fill` |  |

Every sidebar symbol is rendered with `symbolVariant(.fill)` and `foregroundStyle(area.color)`; the list applies `symbolRenderingMode(.hierarchical)`.

Staff Directory keeps orange and Organizations became rust so the two never share a hue; Departments became phthalo green so Classes and Departments differ; Access keeps indigo, shared with Documents. Indigo is the only remaining shared hue, and the two screens are never adjacent. If more areas are added the palette is not extended again without a second differentiator (an icon).

### 4.5 Content category palette

The palette is defined once and shared by every platform; the machine-readable snapshot is published as [tokens.json](https://studentdirectory.app/developer/design/tokens.json).

A category stores a **palette key**, never a hex value. The server and both platforms map the key to a color. The 15 category keys are the 12 Apple system colors (`blue`, `purple`, `orange`, `green`, `pink`, `teal`, `indigo`, `red`, `mint`, `cyan`, `brown`, `yellow`) plus three custom colors: `viridian` (Classes), `rust` (Organizations) and `phthalo` (Departments). A sixteenth color, `mauve`, is **grade-only** (Grade 12) and is not offered to categories.

Each key carries five values, all in the snapshot:

| Role | Used for | Rule |
| --- | --- | --- |
| Fill (`light` / `dark`) | Icons, dots, tiles, tinted backgrounds | The Apple color, or the custom value |
| Pill text (`lightText` / `darkText`) | Label on a 16% tint of the fill | At least 4.5:1 on every web and native surface in both appearances |
| Solid fill (`solid`) | Selected chip, initials circle, action button | The fill itself, or its pill-text color where white on the fill would be under 4.5:1 |
| Label on solid (`onSolid`) | Text on the solid fill | Dark `#1C1C1E` or white, whichever the rule below gives, at least 4.5:1 |

Values (hex, light / dark). Pill text and solid columns are the accessible variants; fills are unchanged.

| Key | Fill | Pill text | Solid fill | Label on solid |
| --- | --- | --- | --- | --- |
| `blue` | `#007AFF` / `#0A84FF` | `#005DC2` / `#51A8FF` | `#005DC2` / `#0A84FF` | white / dark |
| `purple` | `#AF52DE` / `#BF5AF2` | `#9126C8` / `#D189F6` | `#9126C8` / `#BF5AF2` | white / dark |
| `orange` | `#FF9500` / `#FF9F0A` | `#945600` / `#FF9F0A` | `#FF9500` / `#FF9F0A` | dark / dark |
| `green` | `#34C759` / `#30D158` | `#1E7233` / `#30D158` | `#34C759` / `#30D158` | dark / dark |
| `pink` | `#FF2D55` / `#FF375F` | `#BC0024` / `#FF7490` | `#BC0024` / `#FF375F` | white / dark |
| `teal` | `#30B0C7` / `#40C8E0` | `#1D6B79` / `#40C8E0` | `#30B0C7` / `#40C8E0` | dark / dark |
| `indigo` | `#5856D6` / `#5E5CE6` | `#4240B5` / `#9B9AEF` | `#5856D6` / `#5E5CE6` | white / white |
| `red` | `#FF3B30` / `#FF453A` | `#BF0A00` / `#FF7A72` | `#BF0A00` / `#FF453A` | white / dark |
| `mint` | `#00C7BE` / `#63E6E2` | `#00706B` / `#63E6E2` | `#00C7BE` / `#63E6E2` | dark / dark |
| `cyan` | `#32ADE6` / `#64D2FF` | `#126991` / `#64D2FF` | `#32ADE6` / `#64D2FF` | dark / dark |
| `brown` | `#A2845E` / `#AC8E68` | `#755F44` / `#BEA789` | `#755F44` / `#AC8E68` | white / dark |
| `yellow` | `#FFCC00` / `#FFD60A` | `#7A5F00` / `#FFD60A` | `#FFCC00` / `#FFD60A` | dark / dark |
| `viridian` | `#40826D` / `#5FB89B` | `#2F6B58` / `#6ABDA2` | `#40826D` / `#5FB89B` | white / dark |
| `rust` | `#B7410E` / `#E0764A` | `#A43A0D` / `#E79471` | `#B7410E` / `#E0764A` | white / dark |
| `phthalo` | `#005C32` / `#17A058` | `#005C32` / `#1BBF69` | `#005C32` / `#17A058` | white / dark |
| `mauve` (grade-only) | `#B07AA1` / `#C792B9` | `#885178` / `#CE9FC2` | `#885178` / `#C792B9` | white / dark |

Rules:

- **Light mode follows the readable-label rule**: the label on a selected chip, initials circle or action button is dark `#1C1C1E` on yellow, mint, cyan, green, orange and teal, and white on the rest. Where white on the base fill is under 4.5:1 (blue, purple, pink, red, brown, mauve) the solid fill is the darker pill-text color instead, so white passes. **Dark mode:** the system fills are bright in dark mode, so white fails on all of them except indigo. In dark mode the label is therefore dark on every key except indigo.
- **Name-derived fallback** (`null` or unknown key): both platforms now use the same rule. Web now matches native bit for bit (native was not changed, because it drives every shipped avatar color): lowercased, trimmed name, well-known-name table first, else djb2 over UTF-8 with 64-bit wrapping, indexed into the **original 12 keys**. A shared sample-name test (`fallbackSamples` in the snapshot) fails either side on drift. `viridian`, `rust`, `phthalo` and `mauve` are never fallbacks, so existing uncolored names keep their color.
- Default categories seeded per school: Policy `indigo`, Forms `brown`, Admissions `purple`, Athletics `orange`, Transportation `teal`, Academic `blue`, General `cyan`.
- A Notice's "urgent" status is not a category color. It uses the danger/red treatment.

**New colors and how distinguishable they are** (CIEDE2000 distance, then the smaller of the deuteranopia and protanopia simulated distances; 10 or more reads as clearly different, 20 or more as obviously different):

| Pair | Light | Light, color-blind | Dark | Dark, color-blind |
| --- | --- | --- | --- | --- |
| phthalo vs viridian | 16 | 16 | 14 | 15 |
| phthalo vs green | 38 | 38 | 15 | 15 |
| phthalo vs mint | 41 | 42 | 28 | 31 |
| phthalo vs teal | 42 | 43 | 34 | 37 |
| viridian vs green (existing) | 26 | 26 | 17 | 19 |
| rust vs orange | 29 | 24 | 20 | 13 |
| rust vs brown | 24 | 14 | 17 | 6 |
| rust vs red | 16 | 8 | 12 | 3 |
| mauve vs pink (Grade 12 vs K) | 24 | 26 | 24 | 27 |
| mauve vs purple | 18 | 16 | 20 | 18 |

Phthalo is deliberately deep and saturated (light) and mid-lightness (dark) so it is distinct from viridian's gray-green and system green's brightness; in dark mode it is as close to viridian as viridian already is to system green, so the Classes and Departments rows are told apart by their icons as well as color (Principle 2). Rust and red are the closest pair in dark mode for color-blind users; they never sit together and are labelled.

### 4.6 Derived colors

**Grade colors** (`FluxColors.color(forGrade:)`). One color per grade, shared by the grade filter chip, the student's initials circle and the grade pill:

| Grade | K | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Color | pink | red | orange | yellow | green | mint | teal | cyan | blue | indigo | purple | brown | **mauve** |

Grade 12 is mauve (`#B07AA1` light, `#C792B9` dark) so it is no longer the same pink as Kindergarten; mauve is a grade-only palette color (not a category key) with its own pill, solid and text variants (4.5). Distance from pink is 24 (CIEDE2000). No grade (alumni or unknown) is gray. Students who have graduated show the "Alumni" label.

**Meal type colors and symbols**:

| Meal | Symbol | Color |
| --- | --- | --- |
| Breakfast | `sunrise.fill` | orange |
| AM Snack | `carrot.fill` | pink |
| Lunch | `sun.max.fill` | green |
| PM Snack | `moon.stars.fill` | indigo |
| Other | `fork.knife` | teal |

**Calendar category colors and symbols**. The calendar uses a fixed set of four categories plus a fallback, mapped onto the well-known names so colors match the rest of the app:

| Category id | Label | Color (via name) | Symbol |
| --- | --- | --- | --- |
| `general` | General | blue | (default symbol) |
| `performance` | Performance | purple (Arts) | `theatermasks` |
| `closure` | Closure | red (No School) | `xmark.circle` |
| `athletic` | Athletic | orange (Athletics) | `sportscourt` |
| anything else |  | name-derived |  |

**Well-known name colors** (`FluxColors.known`): academic/academics/general blue; athletics/sports orange; arts purple; community/lunch green; holiday/holidays/no school red; meeting/meetings/transportation teal; fundraiser pink; field trip mint; admissions/policy indigo; forms/form brown. These apply when a category has no server palette key.

**Person-name colors**: a person with no photo whose color is not grade-driven (a parent, a staff member) gets `FluxColors.color(for: fullName)`, which is stable per name.

### 4.7 Status and feedback colors

| Meaning | Color | Where |
| --- | --- | --- |
| Urgent / error / destructive | red (`Color.red`, web `danger`) | Urgent banner and row icon, Urgent Only chip, unread dot, error text, Delete buttons |
| Success | green | "Added to your calendar" confirmation |
| Today | orange, everywhere | Today tile in the month grid, the "Today" link in the calendar header, the Lunch Menus week strip |
| Selected | the area color as a solid fill, with its readable label (dark or white, per 4.5) | Chips, calendar day, menu day tile |
| Unread | red dot (8 pt) | Notices list |

### 4.8 School and organization themes

`AppTheme` in `Models/AppTheme.swift` (native) and `lib/theme/*` (web). A school, and optionally an organization, can supply a derived set of tokens:

`accent`, `accentHover`, `onAccent`, `accentText`, `accentMuted`, `background`, `surface`, `border`, `headerBackground`, `headerText`, and optional `highlight`, `highlightText`, `onHighlight`. Each has a light and a dark value (`Color.themed(light:dark:)`).

Rules:

1. **What a theme may change**: the tint (links, selection, prominent buttons: `appTheme(_:)` sets `.tint(theme.accentText)`), the faint page background behind lists and forms (`themedBackground()`), and the navigation bar (`themedNavigationBar()`, a no-op on macOS where the title bar is the system's).
2. **What a theme must not change**: Sunshine area colors, category colors, grade colors, meal and calendar colors, and status colors. The source comment says it directly: "these colors only label things", and "Status colors are never themed."
3. A school with no theme gets the standard Flux look. The sign-in screens and the watch app are never themed.

### 4.9 Contrast and WCAG AA targets

**Targets**:

| Content | Minimum |
| --- | --- |
| Body text, small text (< 18.66 px bold or < 24 px regular) | 4.5:1 |
| Large text | 3:1 |
| UI component boundaries, focus rings, icons that convey information | 3:1 |

Pills, chips, count badges and initials are small text and are held to 4.5:1. Area-colored icons, tiles and dots are decorative or sit beside a text label (Principle 2), so they are not held to 3:1; where an icon is the only signal it needs a text alternative instead.

**Measured pairings** (computed by the unit tests, which fail the build below 4.5:1; the lowest ratio over every surface is shown: web canvases `#FFFFFF`, `#FFFBF3`, `#1E1A2E`, `#272238` and native `.background`/`.background.secondary` in both appearances). "Pill text" is the pill-text color on a 16% tint of the fill; "selected label" is the label on the solid fill of a selected chip, initials circle or action button.

| Key | Pill text, light | Pill text, dark | Selected label, light | Selected label, dark |
| --- | --- | --- | --- | --- |
| `blue` | 4.62 | 4.65 | 6.28 | 4.66 |
| `purple` | 4.68 | 4.72 | 6.33 | 4.83 |
| `orange` | 4.66 | 5.00 | 7.74 | 8.28 |
| `green` | 4.77 | 5.09 | 7.66 | 8.42 |
| `pink` | 4.78 | 4.61 | 6.64 | 4.83 |
| `teal` | 4.79 | 5.10 | 6.61 | 8.55 |
| `indigo` | 5.76 | 4.75 | 5.65 | 5.06 |
| `red` | 4.71 | 4.63 | 6.44 | 4.99 |
| `mint` | 4.72 | 6.25 | 8.03 | 11.30 |
| `cyan` | 4.78 | 5.64 | 6.69 | 9.89 |
| `brown` | 4.62 | 4.73 | 6.04 | 5.53 |
| `yellow` | 5.12 | 6.59 | 11.25 | 12.05 |
| `viridian` | 4.64 | 4.71 | 4.53 | 7.14 |
| `rust` | 4.71 | 4.68 | 5.56 | 5.56 |
| `phthalo` | 5.65 | 4.69 | 8.15 | 5.03 |
| `mauve` | 4.61 | 4.72 | 6.01 | 6.68 |

## 5. Typography

### 5.1 Principles

- **Native**: SF Pro through Dynamic Type text styles. Never fixed point sizes for text that carries content.
- **Rounded for warmth**: the rounded design (`.fontDesign(.rounded)`) is applied to display-level text and initials; it only changes glyph shape, not sizing.
- **Weight carries hierarchy** more than size: titles bold, row titles semibold or regular headline, metadata regular.

### 5.2 SwiftUI type scale

| Role | Style | Weight | Where |
| --- | --- | --- | --- |
| Screen title (student) | `.largeTitle`, rounded | bold | Student detail name |
| Screen title (event) | `.largeTitle` | bold | Calendar event detail |
| Detail title | `.title` | bold | Notice, document, staff, guardian detail |
| Menu date | `.title` / `.title2` | bold | Menu day |
| Staff row name | `.title3` | semibold | Staff directory |
| Row title | `.headline` | regular (notice: medium when read, semibold when unread) | Student, guardian, document, notice rows |
| Row secondary | `.subheadline` | regular, `.secondary` | Guardian names, address, document description |
| Metadata | `.caption` | regular, `.secondary` | Dates, page count, size |
| Pill label | `.footnote` | semibold | `FluxPill` |
| Pill icon | `.caption2` | bold | `FluxPill` |
| Filter chip label | `.subheadline` | semibold | `FluxFilterChips` |
| Count badge | `.caption` | bold, capped at `99+` | `FluxCountBadge` (the only count badge; the private sidebar one was removed) |
| Section label | `.footnote` | semibold, uppercase, `.secondary` | One style everywhere (`FluxSectionLabel`). System `List` section headers keep the system style |
| Action button | `.headline` | regular (headline) | `FluxActionButtonStyle` |
| Banner | `.subheadline` | bold, uppercase | High Priority banner |
| Sidebar section header | `.subheadline`, `.secondary` |  | "My Lists" |
| Initials | `.system(size: diameter × 0.4, weight: .bold, design: .rounded)` | bold | `FluxInitialsCircle` (merged, one font rule everywhere including watchOS) |

Initials text scales with the circle, not with Dynamic Type.

### 5.3 Dynamic Type

Text styles everywhere except initials and icon tiles, which scale with their container (a deliberate exception).

Every list row must remain legible at the largest accessibility sizes. At AX sizes:

- pills may wrap to their own line under the title instead of sitting at the trailing edge,
- avatars stay at their fixed diameters,
- filter chips stay a single horizontally scrolling row.

### 5.4 Web type

`--font-sans: -apple-system, BlinkMacSystemFont, Helvetica, var(--font-geist-sans), sans-serif`. Apple devices render SF Pro; others render the Geist Sans webfont. `--font-mono` is Geist Mono.

| Role | Classes |
| --- | --- |
| Category pill | `text-xs font-medium`, `px-2 py-0.5`, `rounded-full` |
| Filter chip | `text-xs font-medium`, `px-3 py-1`, `rounded-full`, 1 px border |
| Form fields on phones | 16 px minimum below 768 px |

A rem-based web type scale mirrors the native roles in 5.2, so "row title", "metadata" and "pill" are the same relative size on web and scale with the browser's default font size and zoom. Tokens are in `app/app/globals.css` (`@theme`), used as Tailwind utilities in the shared components only (`components/sunshine/*`); pages that carry their own `text-sm`/`text-xs` classes were deliberately not churned.

| Role | Native (5.2) | Web token and utility | Size |
| --- | --- | --- | --- |
| Row title | `.headline` | `--text-row-title`, `text-row-title` | 1.0625rem (17 px), line 1.5rem |
| Row secondary | `.subheadline` | `--text-row-secondary`, `text-row-secondary` | 0.9375rem (15 px), line 1.375rem |
| Metadata | `.caption` | `--text-meta`, `text-meta` | 0.75rem (12 px), line 1rem |
| Pill label | `.footnote` | `--text-pill`, `text-pill` | 0.8125rem (13 px), line 1rem |
| Filter chip | `.subheadline` | `--text-chip`, `text-chip` | 0.875rem (14 px), line 1.25rem. One deliberate difference: native is 15; web chip rows keep 14 so a row fits at 320 px |
| Section label | `.footnote`, semibold, uppercase, secondary | `.section-label` class | 0.8125rem, weight 600, uppercase, 0.04em tracking |

Count badge uses the metadata role (12 px bold).6).

### 5.5 Text rules

- Titles in rows are single line (`lineLimit(1)`), truncated at the tail. Notice and document descriptions show up to 3 lines of preview.
- Previews are plain text from the rich-text body: whitespace-collapsed (`FluxPreviewText.plain`).
- Dates: `abbreviated` in lists (`Oct 13, 2026`), `long` in detail metadata ("Last Updated").
- Detail body text is selectable (`.textSelection(.enabled)`), a deliberate app-wide convention.
- Numbers in count pills are digits, capped at `99+`.

## 6. Iconography

### 6.1 Native (SF Symbols)

- Use **filled** variants for area and identity icons (`symbolVariant(.fill)`, or explicit `.fill` names such as `doc.fill`, `star.fill`, `house.fill`, `phone.fill`, `envelope.fill`, `mappin.circle.fill`, `graduationcap.fill`).
- Sidebar and Settings lists use **hierarchical** rendering (`.symbolRenderingMode(.hierarchical)`).
- Area icons are tinted with the area color; contact icons are tinted by action (phone green, message teal, email blue); neutral metadata icons are `.secondary`.
- Decorative icons are hidden from VoiceOver (`accessibilityHidden(true)`); informative icons carry a label (for example the urgent exclamation has the label "Urgent").
- No custom icon assets, no icon fonts.

Common symbols:

| Concept | Symbol |
| --- | --- |
| Urgent (row) | `exclamationmark.circle.fill` (red) |
| Urgent (banner, chip) | `exclamationmark.triangle.fill` |
| Category pill (notice detail) | `tag.fill` |
| Category pill (document detail) | `folder.fill` |
| File size pill | `internaldrive.fill` |
| Class of (student) | `graduationcap.fill` |
| Address | `house.fill` |
| Location | `mappin.and.ellipse` (events), `mappin.circle.fill` (staff) |
| Send email button | `paperplane.fill` |
| Open document | `arrow.up.right.square` |
| Add to calendar | `calendar.badge.plus` |
| Starred | `star.fill`, yellow |
| Organization pill | `person.3.fill` |
| Link guardian | `link.badge.plus` |
| Registered / pending guardian | `checkmark.circle.fill` / `clock.fill` |
| Add | `plus`; add in capsule: `plus.circle.fill` |
| Remove in capsule | `xmark.circle.fill` |
| Empty states | the area symbol (`doc.text`, `megaphone`, `calendar`, `person.text.rectangle`, `fork.knife.circle`, `person.3`, `star`) |

### 6.2 Web (Lucide)

Web uses Lucide (`lucide-react`) with rounded stroke caps. Lucide is the web analogue of SF Symbols: same concept, similar silhouette, not shared assets.

### 6.3 Sizing

| Context | Size |
| --- | --- |
| Inline with `.footnote` text | `.footnote` or `.caption` font |
| Pill icon | `.caption2` bold |
| Icon tile | `size × 0.48` semibold inside a `size` square (default 40 pt) |
| Contact row icon | `.title3` in a 28 pt wide frame |
| Chevrons in toolbars | `.title3` semibold in a 44 × 44 pt hit area |

## 7. Emoji usage

No emoji appears in any UI string. Iconography is SF Symbols and Lucide only.

1. **Chrome never uses emoji.** Navigation, buttons, pills, banners, empty states, errors and form labels use symbols from section 6. Emoji render differently per platform and per OS version, cannot be tinted, and do not scale with the type system.
2. **User-authored content may contain emoji.** Notice bodies, document titles, list names, category names and student or organization names render emoji exactly as typed. Do not strip, replace or restyle them. Never truncate in the middle of a multi-scalar emoji (use grapheme-aware truncation, which Swift's `lineLimit` and CSS `text-overflow` already provide).
3. **Emoji is never the only signal.** If an admin puts a warning emoji in a title, the urgent flag, not the emoji, is what drives the Urgent treatment.
4. **Initials ignore emoji.** The initials circle uses the first letter of the first and last name and skips non-letter leading characters (emoji, digits, punctuation); a word with no letter is dropped.
5. **No emoji in push notifications, email subjects and calendar exports**, which are copied to places the system does not control. A school can still type them into a title; the product does not add them.
6. **Accessibility**: emoji read aloud by VoiceOver as their names ("red heart"). Avoid runs of emoji in generated text.

## 8. Spacing, layout, shape and elevation

### 8.1 Spacing scale

| Value (pt) | Use |
| --- | --- |
| 2 | Tight vertical padding in a "Linked" badge, staff row padding |
| 4 | Gap in a pill (icon to text), row vertical padding, count badge vertical padding, stack spacing between lines in a row |
| 6 | Gap between an inline badge and a name, pill vertical padding |
| 8 | Gap between chips, vertical padding of the chip scroller |
| 10 | Section content spacing, chip vertical padding (with a 44 pt hit area) |
| 12 | Gap from avatar to text in a row, pill horizontal padding, card inner stack spacing, banner vertical padding |
| 16 | Card padding (including the calendar event card), screen gutter, chip horizontal padding, banner horizontal padding, stack spacing between cards |
| 20 | Staff header padding |
| 24 | Bottom padding on the calendar |

The spacing scale is **2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48** (points on native, pixels on web; Tailwind 0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10, 12), even numbers only. Web publishes the radii and tints as CSS variables next to the palette (`--radius-*`, `--tint-*`).

### 8.2 Layout grid

**Native**:

- Single column on iPhone (stack), three columns on iPad and Mac (`NavigationSplitView`: sidebar, content, detail). The sidebar column is `navigationSplitViewColumnWidth(min: 180, ideal: 220)`.
- Screen gutter 16 pt (list rows use the system inset; detail screens use `.padding()` = 16 pt).
- Detail screens are scrolling stacks of cards separated by 16 pt.

**Web (app plane)**: Tailwind responsive utilities. The shell switches at `lg` (1024 px): below it a mobile header with a menu, above it a sticky sidebar. A separate rule raises form fields to 16 px below 768 px. The named breakpoints are phone below 768 px, tablet 768 to 1023 px, desktop 1024 px and up; component behavior aligns to them.

### 8.3 Corner radii

| Radius | Use |
| --- | --- |
| Capsule (fully rounded) | Pills, chips, count badges, contact action buttons ("Call", "Email") |
| 8 | Document preview thumbnail |
| 10 | Month-grid selected-day tile (34 × 34) |
| 12 | Calendar event date tile (54 × 54) |
| 14 | Action buttons, High Priority banner, menu day tile |
| 16 | Menu image |
| 18 | Cards (`fluxCard`), staff and guardian header panels, calendar event cards |
| `size × 0.28` | Icon tiles (11.2 pt at the default 40 pt) |
| Circle | Avatars and initials |

Radius tokens: `pill` (capsule), `tile` (12), `control` (14), `card` (18), `image` (8 for thumbnails, 16 for hero images). The near-identical 10, 12 and 14 choices for a "small tile" are now 12 for tiles and 14 for controls (buttons, the banner).

Tokens: `--radius-card`, `--radius-panel`.

### 8.4 Elevation and surfaces

No explicit shadows. Depth comes from surface color:

- Cards use `.background.secondary` (which resolves to a slightly elevated grouped surface in both appearances).
- The menu week strip uses `.background.secondary`.
- Standard components (toolbars, tab bars, sidebars, sheets) use the OS's Liquid Glass automatically. Do not reimplement system chrome; use `glassEffect` / `GlassEffectContainer` only for genuinely custom floating controls, and never hard-code blur or tint values.

`fluxCard()` now draws the soft border its comment always promised (1 pt separator on the 18 pt rounded rectangle), and the calendar event card uses it. Web `Card` has the same soft border.

### 8.5 Sizes of repeated elements

| Element | Size |
| --- | --- |
| List avatar (student, guardian) | 48 pt |
| Staff row avatar | 56 pt |
| Parent row avatar in Student Detail | 60 pt |
| Staff and guardian detail avatar | 112 pt |
| Student detail photo | 260 pt |
| Watch student detail photo | 55% of screen width (`screenBounds.width * 0.55`) |
| Default initials circle | 44 pt |
| Default icon tile | 40 pt |
| Action button | full width, 50 pt minimum height |
| Month grid cell | 44 pt minimum height; selected tile 34 × 34 |
| Menu day tile | equal width, 10 pt vertical padding, 6 pt dot |
| Event date tile | 54 × 54 |
| Calendar event dot | 4 pt |
| Toolbar chevron hit area | 44 × 44 pt |
| Unread dot | 8 pt |
| Document preview | max width 420 pt |
| Menu image | 250 pt tall |

## 9. Components

Each component lists its purpose, anatomy, specification, behavior and accessibility.

### 9.1 `FluxPill`

**Purpose**: a small tinted label for a category, grade, relationship, or file size.

**Anatomy**: optional leading symbol, text, capsule.

Text `.footnote` semibold, one line; symbol `.caption2` bold, 4 pt gap; padding 12 horizontal, 6 vertical; foreground = the tone's pill-text color; background = the tone's fill at 0.16 in a `Capsule`.

**Usage**:

- One pill per fact. Place at the trailing edge of a row, or at the top of a detail header.
- Detail-header pills carry an icon (`tag.fill`, `folder.fill`, `internaldrive.fill`, `graduationcap.fill`); row pills do not.
- A pill with no text is not rendered.

**Variants in use**: category (palette color), grade (grade color), "Class of YYYY" (blue), relationship (name-derived color), "Parent" (purple area color), registration state (with `checkmark.circle.fill` or `clock.fill`), file size (secondary).

**Accessibility**: the text is read as-is.

**Web**: `components/sunshine/Pill.tsx`, `rounded-full px-3 py-1.5 text-[13px] font-semibold` on `color-mix(in srgb, var(--cat-<key>) var(--tint-pill), transparent)`, the same size and 16% tint as native.

### 9.2 `FluxCountBadge`

**Purpose**: a count on a sidebar row, color-matched to the row's icon.

`.caption` bold, 8 pt horizontal and 4 pt vertical padding, the tone at 0.16 in a capsule (same rule as a pill; a count badge is a capsule label), digits capped at `99+`. Web: `components/sunshine/CountBadge.tsx`, same rule, with a screen-reader label.

### 9.3 Filter chips (`FluxFilterChips`)

**Purpose**: a single-select filter row above a list (grade, category, urgent, calendar category).

- Horizontally scrolling, no indicators, 8 pt gaps, 16 pt side padding, 8 pt vertical padding.
- First chip is "All" (title configurable). Selecting the selected chip again clears to All, except All itself.
- Chip: `.subheadline` semibold; padding 16 / 9; optional leading symbol (`.caption` bold); capsule.
- **Unselected**: the tone's pill-text color on the tone at 0.16. **Selected**: the tone's solid fill with its readable label (dark text on yellow, mint, cyan, green, orange and teal; white on the rest in light mode; 4.5). Chips are visually about 40 pt tall with a 44 pt hit area.
- `accessibilityAddTraits(.isSelected)` on the selected chip.
- `scrollEdgeEffectHidden(true, for: .all)`, because iOS 26's soft scroll-edge effect would otherwise fade a short scroller under the navigation bar out of sight.

**Rules**:

- Show chips only when there is something to filter. Document and notice category chips appear only when at least one item has a category; the Urgent Only chip appears only while an urgent notice is visible.
- Chip order: All, then Urgent Only (notices), then categories A to Z (`localizedCaseInsensitiveCompare`).
- Only one chip is selected at a time.

**Web**: `components/sunshine/FilterChips.tsx` renders links (`?category=<id>`, plus `urgent=1`), wrapping rather than scrolling. Unselected chips are tinted fills like native (the colored outline is gone); the selected chip is the solid fill with a check mark and `aria-current`. "All" takes the area color. Past 12 chips the rest fold behind a native `<details>` "More", and a pinned "Mine" chip stays next to All. Tap targets are 44 px. Because the chips are links, they work without JavaScript. `CategoryFilterChips` is built on it.

### 9.4 Section header (`FluxSectionHeader`)

A `Label` with a filled SF Symbol tinted in the area color, used for groups in Forms and Settings (for example "Face ID unlock"). Title in the standard section-header style.

### 9.5 Initials circle and avatars

Web: `components/sunshine/InitialsAvatar.tsx`, the same concept.

- Initials by the shared rule (first letter of the first and last word, skipping non-letters, uppercased), `.system(size × 0.4, bold, rounded)`, in the tone's `onSolid` color on its flat `solid` fill (no gradient, so contrast is testable), in a circle. A `tint` parameter picks the tone; with none, the watch's accent-tinted variant is still available. Hidden from VoiceOver. A `(firstName, lastName)` form takes each field's first letter, also skipping non-letters.
- Photo views (`StudentPhotoView`, `GuardianPhotoView`, `StaffPhotoView`) show the photo when present, otherwise the initials fallback.
- Color rules: student = grade color; parent and staff = `FluxColors.color(for: name)`.

**Rules**: a person row always has a leading 48 pt avatar (56 pt for staff). The photo is a circle. Never show an empty gray circle. Initials are decorative and the name is always adjacent text.

### 9.6 Icon tile (`FluxIconTile`)

A rounded square holding an SF Symbol: symbol `size × 0.48` semibold in the tile's color, on the tone at 0.14, corner radius `size × 0.28` continuous. Default 40 pt. Used for the document row (`doc.fill` in the category color, or the Documents area color without a category). The meal and event icons use plain colored symbols or the date tile.

### 9.7 Cards (`fluxCard()`)

Padding 16, full width, leading aligned, `.background.secondary` in a continuous 18 pt rounded rectangle with a soft border. Used for grouped detail sections (header card, body card, attachments card, metadata card) and the calendar event card.

### 9.8 Action button (`FluxActionButtonStyle`)

**Purpose**: the one big primary call to action on a detail screen: "Open Full PDF Document", "Send Email", "Add to Apple Calendar".

Full width, minimum height 50 pt, `.headline`, the tone's readable label on its solid fill, 14 pt continuous radius, 0.8 opacity while pressed.

**Rules**: at most one per section; color is the area color (documents indigo, calendar blue, staff = the person's name color). 50 pt exceeds the 44 pt minimum touch target.

**Contact action capsules**: "Call", "Email", "Text" use a capsule filled with the action color (phone green, message teal, email blue), `.subheadline` semibold white label. A contact capsule is at least 44 pt tall (`minHeight: 44`, still capsule-shaped); web capsules are `min-h-11`.

### 9.9 High Priority banner

`FluxHighPriorityBanner`, `Label("HIGH PRIORITY NOTICE", systemImage: "exclamationmark.triangle.fill")`, `.subheadline` bold, the red tone's readable label on its solid fill, 16 / 12 padding, full width, 14 pt continuous radius. The web notice page shows the same banner and copy. Appears above the notice header card, only for notices whose flags contain `urgent`.

Do not use this banner for anything that is not an urgent notice.

### 9.10 Notice and document header card

**Anatomy**: a card with (top row) a category pill with a leading symbol on the left and either the date (notice) or the size pill (document) on the right, then the title in `.title` bold, then a metadata line (`Expires …` for notices; `doc.fill` + filename + page count for documents).

Notice header also shows the audience tag next to the category pill.

### 9.11 Document preview card

An uppercase "DOCUMENT PREVIEW" section label, then the first page of the PDF rendered with PDFKit (scaled so the long edge is 900 px), centered, maximum 420 pt wide, 8 pt continuous corner radius, white page background. Tapping opens the full document. The preview is cached by attachment id in the Caches directory so a document viewed once previews offline. Multi-page documents show page 1 only; the page count appears in the header and in the metadata row. If the preview cannot be produced (offline on first view, not a PDF), the section is not shown.

The metadata block beneath is label/value rows (File Size, Page Count, Last Updated) separated by dividers, label `.secondary`, value trailing.

### 9.12 Rows

**Student row** (`StudentRowView`): 48 pt photo or grade-colored initials; name `.headline`; a "Linked" badge (accent, `.caption` semibold, 6 / 2 padding) when the student is linked to the viewer; guardian names, street and city/state/zip each on their own `.subheadline` secondary line; trailing grade pill; trailing yellow `star.fill` only when starred (never show an empty star).

**Guardian member row** (`GuardianMemberRowView`): same anatomy as the student row; trailing "Parent" pill in the purple area color; optional contact line.

**Staff row** (`StaffDirectoryView`): 56 pt avatar; name `.title3` semibold; title `.subheadline` secondary; tappable email and phone rows with leading symbols; context menu (Copy, Email, Call).

**Document row**: icon tile, `.headline` title, `.subheadline` description (secondary), category pill, `.caption` metadata (pages, size).

**Notice row**: optional urgent `exclamationmark.circle.fill` (red) and unread dot (8 pt red), `.headline` title (medium read, semibold unread), trailing category pill (or the audience tag when there is no category), up to 3 lines of `.subheadline` preview, `.caption` date. Leading swipe toggles read (matches Apple Mail), trailing swipe is Trash.

**Event row** (`CalendarView`): a 54 × 54 date tile (`.title2` bold day number over `.caption` month, `color.opacity(0.14)`, 12 pt radius), title `.headline`, category pill with the category symbol, time line `.subheadline` secondary, optional location (`mappin.and.ellipse`) and optional result summary (`trophy`) in `.caption` or `.subheadline` secondary. The whole row is an 18 pt rounded card on `.background.secondary`, 14 pt padding.

**Row rules**: vertical padding 4 pt inside the list's own insets; one primary line, up to three secondary lines; the trailing area holds at most one pill plus one status icon.

### 9.13 Empty, loading and error states

- **Empty**: `ContentUnavailableView(title, systemImage:)` with the area symbol (`doc.text`, `person.text.rectangle`, `person.3`, `star`, `fork.knife.circle`), or a short secondary line ("No notices right now."). In the calendar, an italic tertiary line: "No events on this day", "No upcoming events". In the menu: "No menu listed" italic tertiary.
- **Not found**: `ContentUnavailableView("Notice not found", systemImage: "megaphone")`, "Event not found" (calendar), "Document not found" (doc.text.magnifyingglass).
- **Error**: "Something went wrong" with a retry action; inline errors in `.caption` red.
- **Loading**: `ProgressView`; the menu image shows "Imagining what this looks like…" in secondary text.

### 9.14 Forms and pickers

**Category picker** (`CategoryPickerRow`): a menu `Picker` titled "Category" with "None" plus one option per school category, each shown as `circle.fill` tinted with the category color. It is always shown on the Document and Notice edit forms so an item with no category can be given its first one. It shows the last cached list immediately, refreshes on appear (online only), keeps a good list if the refresh fails, and shows a footnote when the list is empty: "No categories yet. A Senior Admin can add them on the web in School Settings." or, after a failed fetch with an empty cache: "Couldn't load categories. Check your connection and reopen this form." Choosing None sends a null. An unchanged selection sends nothing (`CategorySelection.keep`).

**Mark as urgent** toggle on notice create and edit. Hidden for Organization Editors, who send neither category nor flags.

**Web**: `CategorySelect`, `CategoriesManager` (School Settings, Senior Admin) for managing the categories and their palette colors.

**Forms in general**: use native `Form`/`List` sections on Apple platforms; labels sentence case; errors announced (web `role="alert"`).

### 9.15 Calendar and week strip

**Month grid** (`CalendarMonthGrid`): weekday labels `.caption` medium secondary; day cells 44 pt minimum height; selected day = 34 × 34 rounded square (radius 12) filled with the Calendar area color (blue) with its readable label; today = orange 20% tint with orange number; days with events use `.subheadline` semibold and a 4 pt dot beneath; other days `.subheadline`. Selected, today and has-events are conveyed to VoiceOver as a value plus a selected trait (not by color alone).

**Header**: previous and next chevrons in 44 × 44 pt hit areas, month label `.headline`, a "Today" link `.caption` semibold in orange when the visible month is not the current one.

**Below the grid**: category filter chips (Calendar color for All), the selected day's events, then a forward-looking "upcoming" list. The category filter chips sit above the day list.

**Menu week strip** (`MenuWeekView`): month label `.caption` semibold secondary above the week range `.title3` bold, "Jump to Today" `.subheadline` medium in the accent color, 44 pt chevrons. Seven equal tiles: weekday `.caption` semibold, day number `.title3` bold, a 6 pt dot (hidden when the day has no menu). Selected = Lunch Menus green solid fill with dark text (the rule for green) and dot; **today = orange** (14% tint, orange text; it was green); other = `secondary.opacity(0.1)`. Radius 12, vertical padding 10. The strip sits on a `.background.secondary` band.

Selected day content: the full date `.title2` bold, then one block per meal type: icon (`.title2`, meal color), meal name `.title3` bold, chevron, items in `.body`. No times (section 3.3).

### 9.16 Lists restyle (custom Lists)

**Student members**: the same row as the Starred and Students rows (initials circle sized 48 in grade color; name `.headline`); tapping opens Student Detail.

**Parent members** (`GuardianMemberRowView`): 48 pt photo or colored initials, name `.headline`, secondary line (relation or the linked students' names, or "Not linked to a student yet"), optional contact line, trailing "Parent" pill. Tapping opens the linked student's detail when a link resolves; otherwise it opens an independent guardian detail (`GuardianMemberDetailView`).

**Guardian link resolution order** (`ListGuardianLinkResolver`): the linked student id, then an allowlist or email match, then a unique full-name match.

**Guardian detail**: 112 pt avatar in a header panel tinted at 0.14 (18 pt radius), name `.title` bold, relation pill and a registration pill (`checkmark.circle.fill` registered / `clock.fill` pending), then contact rows (phone green "Call", message teal "Text", email blue "Email"), then the linked-students section with `graduationcap.fill`.

### 9.17 Detail headers for people

**Student detail**: photo 260 pt centered; name `.largeTitle` bold rounded centered; grade pill (grade color) and "Class of YYYY" pill (blue, `graduationcap.fill`) side by side; then sections "Parents and Guardians" (one list section per guardian, name `.headline`, relation pill, then contact rows), Address (house icon, standard postal format: street on one line, "City, State Zip" below), organization and list capsules.

**Staff detail**: a header panel tinted at 0.14 (radius 18, padding 20) with a 112 pt photo or colored initials, name `.title` bold, title `.title3` secondary; then Email (with the full-width "Send Email" action button in the person's color), Phone, Location (`mappin.circle.fill`) sections with uppercase `.caption` section labels. The biography appears as a plain section without a title.

## 10. Screen patterns

Each pattern lists the structure and the Sunshine elements it must use.

**Web admin screens:** School Settings, Manage Access, Audiences, Notifications, Account, Audit Log and API Docs use tokens and shared components only (Card, SectionHeader, Pill, 44 px controls, `rounded-2xl`, neutral gray area tone for settings, indigo for Access) and keep their denser form layouts. Swagger UI on the API Docs page sits on a white panel because its theme is light-only.

| Screen | Structure | Sunshine elements |
| --- | --- | --- |
| **Sidebar** | A list of areas, each with a filled colored icon and a matching count pill. Students and grades; My Lists with a "+" in its header; Organizations; school switcher and Profile at the bottom | Area colors, `FluxCountBadge`, hierarchical symbols |
| **Documents list** | Optional category filter chips; rows with icon tile, title, description, category pill, page and size metadata | Category chips and pills, icon tile in the category color |
| **Document detail** | Header card (category pill, size pill, title, filename, page count); a description section; metadata rows; big "Open Full PDF Document" button; document preview | Action button in indigo, preview card |
| **Notices list** | Chips: All, Urgent Only (when any urgent), then categories; rows with urgent icon, unread dot, title, category pill, 3-line preview, date | Urgent red, category pills |
| **Notice detail** | Optional High Priority banner; header card (category pill with `tag.fill`, audience tag, date, title, expiry); rich-text body in a card with no section title; attachments card | Banner, cards |
| **Calendar** | Month grid, category chips, selected day's events, upcoming list; event detail with category pill, `largeTitle` title, location, times, EVENT DETAILS, Add to Apple Calendar button, results section | Calendar blue, orange today |
| **Lunch Menus** | Week strip with Jump to Today; selected day with a block per meal; meal detail retains the generated image | Green area, meal icons and colors; no times |
| **Staff directory** | Rows with 56 pt avatar, name, title, tappable email and phone | Orange area; name-derived avatar color; **department pills (up to two, then +N) and department filter chips when Departments is active** |
| **Students list** | Grade filter chips (color-matched to grade), rows with guardians, address and grade pill; colored placeholders for missing photos | Grade colors, purple All chip |
| **Student detail** | Large photo, name, grade and class-of pills, parents section, address, organizations, lists | Pills, contact capsules |
| **Lists** | List detail with student and parent members styled as in 9.16 | Row reuse |
| **Polls, Permission Slips, Sign-Ups** | Lists and details in their area colors (pink, mint, cyan) | Area colors, count pills |
| **Directory Map** | Whole School / grade / organization chips above a map; teal area | `MapFilterBar` chips |

## 11. Motion

- Selecting a menu day uses `withAnimation` (the default curve).
- Buttons dim to 0.8 opacity while pressed (`FluxActionButtonStyle`).
- Spinners for loading; the launch screen has its own animation and honors Reduce Motion (the only file with a Reduce Motion check).
- Navigation transitions, sheets and list changes use the system defaults.
- No custom haptics.

**Rules**:

1. Use system transitions and spring defaults; no bespoke durations.
2. Any non-system animation must check Reduce Motion (`accessibilityReduceMotion` on native, `prefers-reduced-motion` on web) and replace movement with a fade or an instant change.
3. Nothing animates continuously except a progress indicator.
4. Haptics: a light selection haptic on chip and day selection is acceptable on iPhone; none on macOS or watchOS beyond system behavior. Not currently implemented.

Every custom animation on native honors Reduce Motion (the menu day selection, chip and calendar changes). A single `@media (prefers-reduced-motion: reduce)` rule in `globals.css` collapses every transition and animation to an instant change and turns smooth scrolling off. The custom motion that exists is the sidebar chevron rotation, hover color changes on buttons and the lunch image spinner (a static ring under Reduce Motion, next to its text label). A unit test fails if a component adds an `animate-*` utility without `motion-reduce:animate-none`, and the route crawl asserts no element keeps a running transition under `reducedMotion: reduce`.

## 12. Accessibility

### 12.1 Requirements

| Area | Rule |
| --- | --- |
| Contrast | 4.5:1 text, 3:1 large text and UI |
| Color is not the only signal | Selected chips carry `.isSelected`; calendar days expose today, has-events and selected as values; unread has the label "Unread"; urgent has the label "Urgent" and an icon |
| Touch targets | At least 44 × 44 pt (native), 24 × 24 CSS px minimum (WCAG 2.5.8) with 44 preferred |
| Dynamic Type | Text styles only; layouts reflow rather than clip |
| VoiceOver | Every icon-only button has a label; decorative symbols are hidden; avatars hidden when the name is adjacent; images labeled when informative |
| Reduce Motion | See section 11 |
| Keyboard (macOS, web) | All controls reachable and operable; visible focus; the web has a skip link |
| Forms | Every field has a programmatic label; errors announced |
| Language | `lang="en"` |
| Increase Contrast, Bold Text | Rely on system colors and text styles |
| Voice Control | Use real `Button`s, not tap gestures |

### 12.2 Component accessibility contract

- A pill is text; it must have at least 4.5:1 against its tint (see 4.9).
- A chip is a button with a selected trait and a name equal to its label.
- An avatar is decorative and hidden; the name beside it is the accessible content.
- An icon tile is decorative; the title beside it is the content.
- A banner is a single accessible element whose label is its text.
- A count badge reads as part of its row ("Notices, 3 unread" style). Confirm the combined label.

## 13. Light and dark mode

### 13.1 Rules

1. **Follow the system.** No in-app theme toggle. On Apple platforms there is no `preferredColorScheme` override in production code (the launch screen comment says so explicitly). On the web, dark mode is driven only by `@media (prefers-color-scheme: dark)`, with no JavaScript.
2. **Never hard-code a color that does not adapt.** Use system colors, asset-catalog colors with a dark variant, or CSS variables with a dark override. Fixed white is allowed only for text on a saturated fill.
3. **Tinted fills keep the same alpha in both modes** (16% for pills, chips and count badges; 14% for tiles and panels), because the system colors brighten in dark mode and the tint stays legible against the darker canvas.
4. **Dark surfaces are warm purple-charcoal on the app plane (`#1E1A2E`)**, never pure black.
5. **Text-safe variants differ by mode.** In light mode, small colored text uses the darker `-text` token; in dark mode the base token already clears AA, so the `-text` token equals the base.
6. **Images**: document previews render a white page in both modes (the page is the document, not UI). Student photos are never inverted or dimmed.
7. **Shadows and borders** are subtle in dark mode: prefer a border change over a shadow.

### 13.2 Token pairs

| Token | Light | Dark |
| --- | --- | --- |
| `--background` (app) | `#FFFBF3` | `#1E1A2E` |
| `--surface` (app) | `#FFFFFF` | `#272238` |
| `--foreground` (app) | `#2B2013` | `#F5EEDE` |
| `--foreground-muted` (app) | `#6B5E4D` | `#C9BFAE` |
| `--border` (app) | `#F0E4D0` | `#3A3350` |
| `AccentColor` / `--flux-blue` | `#2E7FCB` | `#74B4EF` |
| `--accent-text` (web default) | `#2563EB` | `#60A5FA` |
| `--accent-muted` (web default) | `#EFF6FF` | `#172554` |
| `--header-icon` | `#71717A` | `#A1A1AA` |
| `--highlight` | `#FFB020` | `#FFC856` |
| `--highlight-text` | `#8A5A00` | `#FFC856` |
| `--cat-<key>`, `--cat-<key>-text` | palette table, section 4.5 | palette dark column; indigo text `#8E8CFF` |
| `--danger` | `#DC2626` | `#F87171` |
| `--warning` | `#D97706` | `#FBBF24` |

Web secondary text in dark mode uses `text-zinc-400` or lighter (the accessibility pass replaced `text-zinc-500` on dark surfaces, 3.5:1, with `dark:text-zinc-400`).

### 13.3 Verification

Increase Contrast is handled by `@media (prefers-contrast: more)` in `globals.css`: border and secondary text tokens get stronger values in both appearances (muted text at least 7:1, borders at least 3:1, unit-tested), while hues, tints and tiles are unchanged. The route crawl (`e2e/tests/app/sunshine-w5-route-crawl.spec.ts`, in `npm run test:a11y`) signs in as a Senior Admin and as a parent of a scratch school with every area seeded, discovers every sidebar destination plus up to three detail or form pages under each, and runs axe (WCAG 2.2 AA) on every page in four appearances: light, dark, light with Increase Contrast, dark with Increase Contrast. Lighthouse accessibility scored 100 on all six pages sampled.

## 14. Data-absent rules and content rules

The UI is data-driven. These rules decide what appears when data is missing.

| Data | When absent | Where |
| --- | --- | --- |
| Document or notice category | No category pill; no category chips if no item has one; the notice row shows the audience tag instead of a pill | Documents, Notices |
| Notice flags | No urgent icon, banner or Urgent Only chip | Notices |
| Event location | The location line does not render | Calendar |
| Photo | Initials circle in the grade or name color | Everywhere |
| Page count (preview) | Page count omitted | Document detail |
| Document description | The description line does not render | Documents list |
| Staff title, email, phone, location | The line or section is omitted (`StaffDirectoryFormatting.nonBlank`) | Staff |
| Guardian name | Falls back to the email (or "Member") | Lists |
| Student address | Address section omitted | Student detail |
| Star | Only starred rows show a star | Rosters |
| Meal | "No menu listed", no dot on the day tile | Menus |
| Department | Up to two department pills (phthalo, the Departments color), then a neutral "+N", on each Staff row, and one department filter chip per department that has staff; shown only while the Departments feature is active and the person is in at least one department (SwiftUI additionally requires `features.departments` to be explicitly true). No empty pill, no chips otherwise | Staff |
| Subject expertise | Not shown: no data; a location section is used instead | Staff |
| Meal times | **Never shown** | Menus |
| Issued by | **Never shown** | Notices |

### Content guidelines

- **Titles**: title case or sentence case as the author wrote them; the UI never changes author text.
- **Descriptions**: first lines appear as a preview; authors should lead with the point.
- **Category names**: short (one or two words); chips scroll, but long names crowd the row. Limit 24 characters.
- **Counts**: digits only.
- **Dates**: abbreviated in lists, long in detail, localized by the system.
- **Placeholders**: examples must be fictional.

## 15. Platform specifications

### 15.1 iOS

- **Navigation**: stack navigation from the sidebar list (the sidebar is the root screen on iPhone). Inline navigation title in the sidebar (`navigationBarTitleDisplayMode(.inline)`).
- **Chrome**: system navigation bars, tab and toolbars with Liquid Glass; school theme may tint the bar (`themedNavigationBar`).
- **Controls**: leading swipe for read and star (matches Apple Mail), trailing swipe for destructive actions; context menus for copy actions.
- **Biometrics**: Face ID or Touch ID unlock overlay on foreground when enabled, with a "Use Passcode" button and a "Sign Out" escape. iOS and iPadOS only (`#if os(iOS)`). The usage string is "Use Face ID to quickly and securely unlock the directory."
- **Widgets**: none.
- **Push**: APNs; tapping a push opens the web app externally (no native deep link yet).
- **Safe areas**: respect them everywhere; filter scrollers sit under the navigation bar with the scroll-edge effect disabled.
- **Minimums**: iOS 26.5; no availability fallbacks for older OSes.

### 15.2 iPadOS

- **Layout**: a three-column `NavigationSplitView` (sidebar, content list, detail). Sidebar width min 180, ideal 220.
- **Detail**: card-based detail scrolls within the detail column; cards stay at full column width (no max width today). Constrain readable text to about 700 pt wide and center it in very wide detail columns.
- **Input**: support pointer hover on rows and chips (system default for `Button`), keyboard shortcuts for primary actions.
- **Multitasking**: Split View and Slide Over supported through the standard size classes. The layout must work in compact width; chips and pills must not rely on a regular width.
- **Presentation**: sheets as form sheets on iPad; popovers for pickers where the system does so.
- Biometric lock is the same as iOS.

### 15.3 macOS

- **Navigation**: the same split view; the sidebar uses the system's source-list look (hierarchical symbols, colored icons preserved).
- **Windows**: School Settings, Access and the Directory Map open in their own windows (`WindowGroup(id:)` keyed by a context value to dedupe), because they are too wide for a split-view column. A `Settings` scene provides the standard Settings window (Cmd+,).
- **Input**: pointer and keyboard first. Context menus on rows (Copy, Email, Call). Text in details is selectable. No swipe actions as the primary affordance; expose the same actions in context menus and toolbars. Full Keyboard Access for tap-gesture rows is a known limitation.
- **Biometrics**: none; Touch ID is not used on Mac in this app.
- **Chrome**: the title bar is the system's; the school theme does not tint it.
- **Sizes**: sheets declare minimum sizes (for example 420 × 480). Pill and chip sizes are the same as iOS; hit areas can be smaller on a pointer but the specification keeps the iOS sizes for consistency.
- **Menus**: standard menu bar; no custom menu extras.
- **Not available**: Contact Export, message sending, biometric lock.

### 15.4 watchOS

The watch app is read-only. It lists students (name, grade, and a star indicator), shows a Contacts-style detail (a photo at 55% of the screen width, or the initials fallback; guardian sections with tap-to-call, long-press-to-text and tap-to-email; address to Maps; read-only private note), a school switcher for multi-school parents, and a static-launcher complication (all four accessory families, app icon only). Data comes from the phone through WatchConnectivity.

**Sunshine status**: **not applied.** The watch uses `InitialsAvatarView` without a tint (accent-tinted initials, not grade colors), `.headline` and `.caption` text, and a yellow star. The Sunshine layer is not applied to the watch app yet.

### 15.5 Web: app plane

- **Shell**: header and sidebar. Below `lg` (1024 px) a mobile header with a menu; at `lg` and above a sticky sidebar. A skip link and `main#main-content` landmark exist.
- **Canvas**: warm paper `#FFFBF3`, surfaces white, plum-charcoal in dark mode.
- **Shape**: `rounded-2xl` and `rounded-3xl` cards, pill buttons, warm-tinted shadows.
- **Typography**: SF Pro on Apple, Geist Sans elsewhere; 16 px form fields below 768 px.
- **Icons**: Lucide.
- **Sunshine on web today**: the shared tokens (`--cat-*`, `--area-*`, `--grade-*`, `--tint-*`, `--radius-*`) and the components in `app/components/sunshine/` (Pill, CountBadge, FilterChips, InitialsAvatar, IconTile, SectionHeader, Card, StudentRow, PersonRow, ActionButton), plus `CategoryPill`, `CategoryFilterChips` and the Staff department pills and chips. Admin screens get tokens and components only for now. `components/sunshine/DetailHeader.tsx` (name-derived panel at `--tint-tile`, 160 px photo or initials on Student Detail, 128 px on Staff Detail), `ContactCapsule.tsx` (44 px, the number or address itself, call green, text teal, email blue, tint layered over the surface so it holds 4.5:1 on a tinted panel) and `Pill solidBase`; the section cards use `Card` and `SectionHeader` with the area icon. The web sidebar uses one filled Lucide icon per area in its area color, `CountBadge` for counts with a spoken name, and the area-colored selected row with `aria-current="page"`.
- **Dark mode**: `prefers-color-scheme` only.
- **PWA**: `manifest.json` in `/public`, referenced from the root layout so `/login` and `/register` get it too; icons `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`, `apple-touch-icon.png` (opaque). **No service worker, by design.** Installability only, no offline caching or push on the web. Do not add `next-pwa`.
- **Responsive**: phone-first for parents; admin screens remain usable at tablet width. Tables collapse or scroll horizontally on phones. Category chips wrap (`flex-wrap`) on web rather than scroll.
- **No JavaScript dependence** for filters: chips are links.
- **Map**: Google Maps on web, Apple MapKit on Apple platforms (a deliberate difference).

## 16. Localization and internationalization

English only (`lang="en"`). Strings are inline in views, not in a string catalog.

## 17. Extending the system

### 17.1 Definition of done for a new component

- Uses only tokens from this specification.
- Has an entry in section 9 with sizes, states and accessibility.
- Handles absent data (section 14).
- Passes the contrast targets in 4.9.
- Works in light, dark, large type and VoiceOver.

## Appendix A. Token reference

### A.1 SwiftUI

| Token | Definition |
| --- | --- |
| `FluxArea.<area>.color` | See 4.4 |
| `FluxColors.palette` | blue, purple, orange, green, pink, teal, indigo, red, mint, cyan, brown, yellow (the name-derived fallback uses only these) |
| `FluxColors.color(for:)` | Known-name color, else djb2 index into `palette` |
| `FluxColors.color(forGrade:)` | Grade table in 4.6 |
| `CategoryColor.keys` / `.color(forKey:)` / `.color(key:name:)` | Palette key to color, with name fallback |
| `MealType.icon`, `MealType.color` | Meal table in 4.6 |
| `CalendarCategory.color(_:)` | Calendar table in 4.6 |
| `FluxPill` | 12 / 6 padding, footnote semibold, 0.16 tint, pill-text color, capsule |
| `FluxCountBadge` | 8 / 4 padding, caption bold, 0.16 tint, `99+` cap |
| `FluxFilterChips` | 16 / 8-10 padding, subheadline semibold, 0.16 tint, selected solid + readable label |
| `FluxInitialsCircle` | size × 0.4 bold rounded, solid fill + readable label, circle (watchOS too) |
| `FluxIconTile` | 0.48 symbol, 0.14 tint, radius 0.28 × size |
| `FluxActionButtonStyle` | 50 pt min height, radius 14, headline, solid + readable label |
| `fluxCard()` | padding 16, radius 18, `.background.secondary`, soft border |
| `FluxTone` / `CategoryColor.textColor`, `solidColor`, `onSolidColor` | The adaptive fill, pill-text, solid and on-solid colors for a palette key |
| `FluxSectionLabel`, `FluxHighPriorityBanner`, `withFluxAnimation` | One section-label style; the banner; Reduce Motion aware animation |
| `AccentColor`, `SunColor`, `CoralColor`, `LeafColor` | Asset catalog colors with dark variants |
| `AppTheme` | School theme roles with light/dark pairs |

### A.2 Web

| Token | Light | Dark |
| --- | --- | --- |
| `--background`, `--surface`, `--foreground`, `--foreground-muted`, `--border` | Section 4.3 | Section 4.3 |
| `--flux-blue`, `--sun`, `--coral`, `--leaf`, `--warning`, `--danger` | Section 4.2 | Section 4.2 |
| `--flux-blue-text`, `--coral-text`, `--leaf-text`, `--sun-text` | Section 4.2 | = base |
| `--accent`, `--accent-hover`, `--on-accent`, `--accent-text`, `--accent-muted`, `--highlight`, `--highlight-text`, `--on-highlight`, `--header-bg`, `--header-text`, `--header-icon` | Defaults reproduce the standard blue look; a school theme overrides |  |
| `--cat-<key>`, `-text`, `-solid`, `-on-solid` | Section 4.5 | Section 4.5 |
| `--area-<name>`, `--grade-<n>` (each with `-text`, `-solid`, `-on-solid`) | Section 4.4, 4.6 | same |
| `--tint-pill`, `--tint-tile`, `--radius-*` | 16%, 14%; 4.8 radii | same |
| `--font-sans`, `--font-mono` | SF Pro / Geist Sans stack; Geist Mono |  |

### A.3 Shared

| Token | Rule |
| --- | --- |
| Pill, chip, count badge tint | 16% on both platforms; icon tile and panel 14% |
| Selected chip, initials circle, action button | the tone's solid fill with its readable label (4.5) |
| Urgent | red / danger, never a category color |
| Touch target | 44 pt minimum |
| Corner radius | capsule for pills and chips; native 12 tile, 14 control, 18 card; web rounded-2xl cards and inputs, rounded-3xl large panels |
| Spacing scale | 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48 |

## Sample imagery

Vector samples (SVG, light and dark) and live web samples, by section heading pattern:

- Sunshine area colors: Web: rendered live as HTML and CSS on the web page, no image file (light and dark) - Rounded square icon tiles, one for each app area (starred, lunch menus, calendar and so on), each tinted in that area's color with a matching label.
- Content category palette: Web: rendered live as HTML and CSS on the web page, no image file (light and dark) - Row of tinted capsule pills, one for each palette key (blue, purple, orange and so on), plus a mauve pill, a neutral pill, and a Class of 2030 pill with an icon.
- FluxPill: iPhone: https://studentdirectory.app/developer/images/native/iphone-pills-light.svg (light SVG), https://studentdirectory.app/developer/images/native/iphone-pills-dark.svg (dark SVG) - Vector sample at iPhone width: category pills in every palette color, grade pills from K to 12th and Alumni, pills with icons, and count badges.; iPad: https://studentdirectory.app/developer/images/native/ipad-pills-light.svg (light SVG), https://studentdirectory.app/developer/images/native/ipad-pills-dark.svg (dark SVG) - Vector sample at iPad width: category pills in every palette color, grade pills, pills with icons, and count badges.
- FluxCountBadge: Web: rendered live as HTML and CSS on the web page, no image file (light and dark) - Small capsule count badges in red, pink, mint and cyan showing 3, 12, 99 and 99 plus, and a neutral badge showing 7.
- Filter chips: Web: rendered live as HTML and CSS on the web page, no image file (light and dark) - Three rows of filter chips: Urgent only, Policy (selected), Forms, Athletics and Transportation; the same row with All selected; and a long row of subject chips with a More control and Mine pinned first.; iPhone: https://studentdirectory.app/developer/images/native/iphone-controls-light.svg (light SVG), https://studentdirectory.app/developer/images/native/iphone-controls-dark.svg (dark SVG) - Vector sample at iPhone width: a row of filter chips with one selected, a grade filter row, big action buttons, contact capsules and the High Priority banner.; iPad: https://studentdirectory.app/developer/images/native/ipad-controls-light.svg (light SVG), https://studentdirectory.app/developer/images/native/ipad-controls-dark.svg (dark SVG) - Vector sample at iPad width: filter chips, action buttons, contact capsules and the High Priority banner.
- Initials circle and avatars: Web: rendered live as HTML and CSS on the web page, no image file (light and dark) - Round initials avatars, one for each grade from K to 12 in that grade's color, followed by larger avatars for Ada Lovelace, Mrs. Rodriguez and Pat Lee.; iPhone: https://studentdirectory.app/developer/images/native/iphone-people-light.svg (light SVG), https://studentdirectory.app/developer/images/native/iphone-people-dark.svg (dark SVG) - Vector sample at iPhone width: initials circles in several colors, icon tiles in area colors, a card with a staff member and department pills, and Today and Selected day tiles.; iPad: https://studentdirectory.app/developer/images/native/ipad-people-light.svg (light SVG), https://studentdirectory.app/developer/images/native/ipad-people-dark.svg (dark SVG) - Vector sample at iPad width: initials circles, icon tiles, a card, and Today and Selected day tiles.
- Icon tile: Web: rendered live as HTML and CSS on the web page, no image file (light and dark) - Rounded square icon tiles tinted in each area color with a label beside each.
- Cards: Web: rendered live as HTML and CSS on the web page, no image file (light and dark) - Two cards side by side: a standard rounded card with a soft border, and a larger panel with a rounder corner and more padding.
- Action button: Web: rendered live as HTML and CSS on the web page, no image file (light and dark) - Grid of full-width rounded action buttons, each in a different area color (documents, calendar, staff, lunch menus, notices, organizations, classes, departments) with a document icon and a label.
- Rows: Web: rendered live as HTML and CSS on the web page, no image file (light and dark) - List of three rows: a student with initials, grade pill, guardian names and address; a student with only a name and a gray no-grade avatar; and a teacher with three department pills and call, text and email capsules.; Web: rendered live as HTML and CSS on the web page, no image file (light and dark) - Two content rows, each a rounded card with a colored icon tile, a title, a category pill and a short description.
- Detail headers for people: Web: rendered live as HTML and CSS on the web page, no image file (light and dark) - Large rounded panel tinted in the person's color with a big initials avatar, the name, grade and class pills, and call, text and email contact capsules.
