This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
499 lines
14 KiB
Markdown
499 lines
14 KiB
Markdown
---
|
||
name: axiom-typography-ref
|
||
description: Apple platform typography reference (San Francisco fonts, text styles, Dynamic Type, tracking, leading, internationalization) through iOS 26
|
||
license: MIT
|
||
---
|
||
|
||
# Typography Reference
|
||
|
||
Complete reference for typography on Apple platforms including San Francisco font system, text styles, Dynamic Type, tracking, leading, and internationalization through iOS 26.
|
||
|
||
## San Francisco Font System
|
||
|
||
### Font Families
|
||
|
||
**SF Pro** and **SF Pro Rounded** (iOS, iPadOS, macOS, tvOS)
|
||
- Main system fonts for most UI elements
|
||
- Rounded variant for friendly, approachable interfaces (e.g., Reminders app)
|
||
|
||
**SF Compact** and **SF Compact Rounded** (watchOS, narrow columns)
|
||
- Optimized for constrained spaces and small sizes
|
||
- watchOS default system font
|
||
|
||
**SF Mono** (Code environments, monospaced text)
|
||
- Monospaced font for code editors and technical content
|
||
- Consistent character widths for alignment
|
||
|
||
**New York** (Serif system font)
|
||
- Serif alternative for editorial content
|
||
- Works with text styles just like SF Pro
|
||
|
||
### Variable Font Axes
|
||
|
||
#### Weight Axis (9 weights)
|
||
- Ultralight, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black
|
||
- Continuous weight spectrum via variable fonts
|
||
- Avoid light weights at small sizes (legibility issues)
|
||
|
||
#### Width Axis (WWDC 2022)
|
||
- **Condensed** — narrowest width
|
||
- **Compressed** — narrow width
|
||
- **Regular** — standard width (default)
|
||
- **Expanded** — wide width
|
||
|
||
Access via:
|
||
```swift
|
||
// iOS/macOS
|
||
let descriptor = UIFontDescriptor(fontAttributes: [
|
||
.family: "SF Pro",
|
||
kCTFontWidthTrait: 1.0 // 1.0 = Expanded
|
||
])
|
||
```
|
||
|
||
**SF Arabic** (WWDC 2022)
|
||
- Matches SF Pro design language for Arabic text
|
||
- Proper right-to-left support
|
||
|
||
#### Optical Sizes
|
||
Variable fonts automatically adjust optical size based on point size:
|
||
- **Text variant** (< 20pt) — more spacing, sturdier strokes
|
||
- **Display variant** (≥ 20pt) — tighter spacing, refined details
|
||
- **Smooth transition** (17-28pt) with variable SF Pro
|
||
|
||
From WWDC 2020:
|
||
> "TextKit 2 abstracts away glyph handling to provide a consistent experience for international text."
|
||
|
||
## Text Styles & Dynamic Type
|
||
|
||
### System Text Styles
|
||
|
||
| Text Style | Default Size (iOS) | Use Case |
|
||
|------------|-------------------|----------|
|
||
| `.largeTitle` | 34pt | Primary page headings |
|
||
| `.title` | 28pt | Secondary headings |
|
||
| `.title2` | 22pt | Tertiary headings |
|
||
| `.title3` | 20pt | Quaternary headings |
|
||
| `.headline` | 17pt (Semibold) | Emphasized body text |
|
||
| `.body` | 17pt | Primary body text |
|
||
| `.callout` | 16pt | Secondary body text |
|
||
| `.subheadline` | 15pt | Tertiary body text |
|
||
| `.footnote` | 13pt | Footnotes, captions |
|
||
| `.caption` | 12pt | Small annotations |
|
||
| `.caption2` | 11pt | Smallest annotations |
|
||
|
||
#### Font Size Guidance
|
||
|
||
- **Avoid `.caption2` for readable content** — at 11pt, it's acceptable for timestamps and metadata annotations but too small for body text or labels users need to read. Prefer `.caption` or `.footnote` as the minimum for readable content.
|
||
|
||
### Emphasized Text Styles
|
||
|
||
Apply `.bold` symbolic trait to get emphasized variants:
|
||
|
||
```swift
|
||
// UIKit
|
||
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title1)
|
||
let boldDescriptor = descriptor.withSymbolicTraits(.traitBold)!
|
||
let font = UIFont(descriptor: boldDescriptor, size: 0)
|
||
|
||
// SwiftUI
|
||
Text("Bold Title")
|
||
.font(.title.bold())
|
||
```
|
||
|
||
**Actual weights by text style:**
|
||
- Some styles map to **medium**
|
||
- Others map to **semibold**, **bold**, or **heavy**
|
||
- Depends on semantic hierarchy
|
||
|
||
### Leading Variants
|
||
|
||
**Tight Leading** (reduces line height by 2pt on iOS, 1pt on watchOS):
|
||
```swift
|
||
// UIKit
|
||
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
||
let tightDescriptor = descriptor.withSymbolicTraits(.traitTightLeading)!
|
||
|
||
// SwiftUI
|
||
Text("Compact text")
|
||
.font(.body.leading(.tight))
|
||
```
|
||
|
||
**Loose Leading** (increases line height by 2pt on iOS, 1pt on watchOS):
|
||
```swift
|
||
// SwiftUI
|
||
Text("Spacious paragraph")
|
||
.font(.body.leading(.loose))
|
||
```
|
||
|
||
### Dynamic Type
|
||
|
||
**Automatic Scaling** (iOS):
|
||
Text styles scale automatically based on user preferences from Settings → Display & Brightness → Text Size.
|
||
|
||
**Custom Fonts with Dynamic Type:**
|
||
|
||
```swift
|
||
// UIKit - UIFontMetrics
|
||
let customFont = UIFont(name: "Avenir-Medium", size: 34)!
|
||
let bodyMetrics = UIFontMetrics(forTextStyle: .body)
|
||
let scaledFont = bodyMetrics.scaledFont(for: customFont)
|
||
|
||
// Also scale constants
|
||
let spacing = bodyMetrics.scaledValue(for: 20.0)
|
||
```
|
||
|
||
```swift
|
||
// SwiftUI - .font(.custom(_:relativeTo:))
|
||
Text("Custom scaled text")
|
||
.font(.custom("Avenir-Medium", size: 34, relativeTo: .body))
|
||
|
||
// @ScaledMetric for values
|
||
@ScaledMetric(relativeTo: .body) var padding: CGFloat = 20
|
||
```
|
||
|
||
### Platform Differences
|
||
|
||
**macOS**
|
||
- No Dynamic Type support in AppKit
|
||
- Text style sizes optimized for macOS control sizes
|
||
- Catalyst apps use iOS sizes × 77% (legacy) or macOS-optimized sizes ("Optimize Interface for Mac")
|
||
|
||
**watchOS**
|
||
- Smaller text styles optimized for watch faces
|
||
- Tight leading default for compact displays
|
||
|
||
**visionOS**
|
||
- System fonts work identically to iOS
|
||
- Dynamic Type support included
|
||
|
||
## Tracking & Leading
|
||
|
||
### Tracking (Letter Spacing)
|
||
|
||
Tracking adjusts space between letters. Essential for optical size behavior.
|
||
|
||
**Size-Specific Tracking Tables:**
|
||
|
||
SF Pro includes tracking values that vary by point size to maintain optimal spacing:
|
||
- Larger sizes: tighter tracking
|
||
- Smaller sizes: looser tracking
|
||
|
||
Example from Apple Design Resources:
|
||
- 34pt (largeTitle): +0.016 tracking
|
||
- 17pt (body): +0.008 tracking
|
||
- 11pt (caption2): +0.06 tracking
|
||
|
||
**Tight Tracking API** (for fitting text):
|
||
```swift
|
||
// UIKit
|
||
textView.allowsDefaultTightening(for: .byTruncatingTail)
|
||
|
||
// SwiftUI
|
||
Text("Long text that needs to fit")
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.5) // Allows tight tracking
|
||
```
|
||
|
||
**Manual Tracking:**
|
||
```swift
|
||
// UIKit
|
||
let attributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.preferredFont(forTextStyle: .body),
|
||
.kern: 2.0 // 2pt tracking
|
||
]
|
||
|
||
// SwiftUI
|
||
Text("Tracked text")
|
||
.tracking(2.0)
|
||
.kerning(2.0) // Alternative API
|
||
```
|
||
|
||
**Important:** Use `.tracking()` not `.kerning()` API for semantic correctness. Tracking disables ligatures when necessary; kerning does not.
|
||
|
||
### Leading (Line Spacing)
|
||
|
||
**Default Line Height:**
|
||
Calculated from font's built-in metrics (ascender + descender + line gap).
|
||
|
||
**Language-Aware Adjustments:**
|
||
iOS 17+ automatically increases line height for scripts with tall ascenders/descenders:
|
||
- Arabic
|
||
- Thai, Lao
|
||
- Hindi, Bengali, Telugu
|
||
|
||
From WWDC 2023:
|
||
> "Automatic line height adjustment for scripts with variable heights"
|
||
|
||
**Manual Leading:**
|
||
```swift
|
||
// UIKit
|
||
let paragraphStyle = NSMutableParagraphStyle()
|
||
paragraphStyle.lineSpacing = 8.0 // 8pt additional space
|
||
|
||
// SwiftUI (iOS 13+)
|
||
Text("Custom spacing")
|
||
.lineSpacing(8.0)
|
||
```
|
||
|
||
**Line Height (iOS 26+):**
|
||
|
||
`.lineHeight()` sets baseline-to-baseline distance directly — more intuitive than `.lineSpacing()` (which measures bottom-to-top).
|
||
|
||
```swift
|
||
// Presets
|
||
Text("Open layout").lineHeight(.loose)
|
||
Text("Compact layout").lineHeight(.tight)
|
||
|
||
// Precise control
|
||
Text("Scaled").lineHeight(.multiple(factor: 1.5))
|
||
Text("Fixed").lineHeight(.exact(points: 30)) // Does NOT scale with Dynamic Type
|
||
```
|
||
|
||
Also available as `AttributedString.lineHeight` for styled strings. See `axiom-swiftui-26-ref` for full API details.
|
||
|
||
### Third-Party Font Tracking
|
||
|
||
**New in iOS 18:**
|
||
Font vendors can embed tracking tables in custom fonts using STAT table + CTFont optical size attribute.
|
||
|
||
```swift
|
||
let attributes: [String: Any] = [
|
||
kCTFontOpticalSizeAttribute as String: pointSize
|
||
]
|
||
let descriptor = CTFontDescriptorCreateWithAttributes(attributes as CFDictionary)
|
||
let font = CTFontCreateWithFontDescriptor(descriptor, pointSize, nil)
|
||
```
|
||
|
||
## SwiftUI AttributedString Typography
|
||
|
||
### Font Environment Interaction
|
||
|
||
**Critical Pattern** When using `AttributedString` with SwiftUI's `Text`, paragraph styles (like `lineHeightMultiple`) can be lost if fonts come from the environment instead of the attributed content.
|
||
|
||
From WWDC 2025-280:
|
||
> "TextEditor substitutes the default value calculated from the environment for any AttributedStringKeys with a value of nil."
|
||
|
||
This same principle applies to `Text`—when your `AttributedString` doesn't specify a font, SwiftUI applies the environment font, which can cause it to rebuild text runs and drop or normalize paragraph style details.
|
||
|
||
### The Problem
|
||
|
||
```swift
|
||
// ❌ WRONG - .font() modifier can override and drop paragraph styles
|
||
var s = AttributedString(longString)
|
||
|
||
// Set paragraph style
|
||
var p = AttributedString.ParagraphStyle()
|
||
p.lineHeightMultiple = 0.92
|
||
s.paragraphStyle = p
|
||
// ⚠️ No font set in AttributedString
|
||
|
||
Text(s)
|
||
.font(.body) // ⚠️ May rebuild runs, lose lineHeightMultiple
|
||
```
|
||
|
||
**Why this fails:**
|
||
1. `AttributedString` has no font attribute set (value is `nil`)
|
||
2. SwiftUI's `.font(.body)` modifier tells it "use this font for the whole run"
|
||
3. SwiftUI rebuilds text runs with the environment font
|
||
4. Paragraph styles get dropped or normalized during rebuild
|
||
|
||
### The Solution
|
||
|
||
**Keep typography inside the AttributedString when you need fine control:**
|
||
|
||
```swift
|
||
// ✅ CORRECT - Font in AttributedString, no environment override
|
||
var s = AttributedString(longString)
|
||
|
||
// Set font INSIDE the attributed content
|
||
s.font = .system(.body) // ✅ Typography inside AttributedString
|
||
|
||
// Set paragraph style
|
||
var p = AttributedString.ParagraphStyle()
|
||
p.lineHeightMultiple = 0.92
|
||
s.paragraphStyle = p
|
||
|
||
Text(s) // ✅ No .font() modifier
|
||
```
|
||
|
||
**Why this works:**
|
||
1. Font is part of the attributed content (not `nil`)
|
||
2. No environment override from `.font()` modifier
|
||
3. SwiftUI preserves both font AND paragraph styles
|
||
4. Text runs remain intact with all attributes
|
||
|
||
### When to Use Each Approach
|
||
|
||
#### Use Font in AttributedString (Fine Control)
|
||
|
||
```swift
|
||
var s = AttributedString("Carefully styled text")
|
||
s.font = .system(.body)
|
||
|
||
var p = AttributedString.ParagraphStyle()
|
||
p.lineHeightMultiple = 0.92
|
||
p.alignment = .leading
|
||
s.paragraphStyle = p
|
||
|
||
Text(s) // No modifier
|
||
```
|
||
|
||
**When to use:**
|
||
- Need precise paragraph styling (line height, alignment)
|
||
- Mixing multiple fonts in one string
|
||
- Content will be displayed in both `Text` and `TextEditor`
|
||
- Preserving exact formatting from rich text editor
|
||
|
||
#### Use .font() Modifier (Broad Override)
|
||
|
||
```swift
|
||
Text("Simple text")
|
||
.font(.body)
|
||
.lineSpacing(4.0) // SwiftUI-level spacing
|
||
```
|
||
|
||
**When to use:**
|
||
- Simple text without paragraph styles
|
||
- Want Dynamic Type automatic scaling
|
||
- Need SwiftUI's semantic font behavior (Dark Mode, accessibility)
|
||
- Intentionally overriding AttributedString fonts
|
||
|
||
### Multiple Fonts in One String
|
||
|
||
```swift
|
||
var s = AttributedString("Title")
|
||
s.font = .system(.title).bold()
|
||
|
||
var body = AttributedString(" and body text")
|
||
body.font = .system(.body)
|
||
|
||
s.append(body)
|
||
|
||
Text(s) // ✅ No .font() modifier preserves both fonts
|
||
```
|
||
|
||
### Common Mistake: Order Doesn't Matter
|
||
|
||
```swift
|
||
// ❌ WRONG mental model: "Create AttributedString first"
|
||
var s = AttributedString(text)
|
||
var p = AttributedString.ParagraphStyle()
|
||
p.lineHeightMultiple = 0.92
|
||
s.paragraphStyle = p
|
||
s.font = .system(.body) // ⚠️ Setting font last doesn't help if you use .font() modifier
|
||
|
||
Text(s).font(.body) // Still breaks!
|
||
```
|
||
|
||
The issue isn't **when** you set the font in `AttributedString`. The issue is **whether the attributed content carries its own font attributes** versus relying on SwiftUI's `.font(...)` environment.
|
||
|
||
### Verification Checklist
|
||
|
||
When using `AttributedString` with paragraph styles:
|
||
- [ ] Font set inside `AttributedString` (not `nil`)
|
||
- [ ] No `.font()` modifier on `Text` view (unless intentionally overriding)
|
||
- [ ] Paragraph styles set after or before font (order doesn't matter)
|
||
- [ ] Tested with actual content to verify line height/alignment preserved
|
||
|
||
## Internationalization
|
||
|
||
### Bidirectional Text
|
||
|
||
**Complex Script Example (from WWDC 2021):**
|
||
|
||
Kannada word "October":
|
||
- Character index 4 has split vowel → 2 glyphs
|
||
- Glyphs reorder before ligature application
|
||
- Glyph index ≠ character index
|
||
|
||
This is why TextKit 2 uses **NSTextLocation** instead of integer indices.
|
||
|
||
**Hebrew/Arabic Selection:**
|
||
Single visual selection = multiple NSRanges in AttributedString due to right-to-left layout.
|
||
|
||
### Line Breaking
|
||
|
||
**Language-Aware (iOS 17+):**
|
||
- Chinese, Japanese, Korean: break at semantic boundaries
|
||
- German: avoid breaking compound words
|
||
- English: prefer breaking at hyphens
|
||
|
||
**Even Line Breaking (TextKit 2):**
|
||
Justified paragraphs use improved line breaking algorithm:
|
||
- Reduces stretched-out lines
|
||
- More even interword spacing
|
||
- Automatic in TextKit 2
|
||
|
||
### Text Clipping Prevention
|
||
|
||
**Best Practices:**
|
||
1. Use Dynamic Type (auto-adjusts)
|
||
2. Set `.lineLimit(nil)` or `.lineLimit(2...5)` in SwiftUI
|
||
3. Use `.minimumScaleFactor()` for constrained single-line text
|
||
4. Test with large accessibility sizes
|
||
|
||
## CSS & Web Typography
|
||
|
||
**System UI Font Families:**
|
||
|
||
```css
|
||
font-family: system-ui; /* SF Pro */
|
||
font-family: ui-rounded; /* SF Pro Rounded */
|
||
font-family: ui-serif; /* New York */
|
||
font-family: ui-monospace; /* SF Mono */
|
||
```
|
||
|
||
**Legacy:**
|
||
```css
|
||
font-family: -apple-system; /* deprecated, use system-ui */
|
||
```
|
||
|
||
## Code Examples
|
||
|
||
### Emphasized Large Title (SwiftUI)
|
||
```swift
|
||
Text("Recipe Editor")
|
||
.font(.largeTitle.bold()) // Emphasized variant
|
||
```
|
||
|
||
### Custom Font + Dynamic Type (UIKit)
|
||
```swift
|
||
let customFont = UIFont(name: "Avenir-Medium", size: 17)!
|
||
let metrics = UIFontMetrics(forTextStyle: .body)
|
||
label.font = metrics.scaledFont(for: customFont)
|
||
label.adjustsFontForContentSizeCategory = true
|
||
```
|
||
|
||
### Rounded Design (UIKit)
|
||
```swift
|
||
let descriptor = UIFontDescriptor
|
||
.preferredFontDescriptor(withTextStyle: .largeTitle)
|
||
.withDesign(.rounded)!
|
||
let font = UIFont(descriptor: descriptor, size: 0)
|
||
```
|
||
|
||
### Rounded Design (SwiftUI)
|
||
```swift
|
||
Text("Today")
|
||
.font(.largeTitle.bold())
|
||
.fontDesign(.rounded)
|
||
```
|
||
|
||
### ScaledMetric (SwiftUI)
|
||
```swift
|
||
struct RecipeView: View {
|
||
@ScaledMetric(relativeTo: .body) var padding: CGFloat = 20
|
||
|
||
var body: some View {
|
||
Text("Recipe")
|
||
.padding(padding) // Scales with Dynamic Type
|
||
}
|
||
}
|
||
```
|
||
|
||
## Resources
|
||
|
||
**WWDC**: 2020-10175, 2022-110381, 2023-10058
|
||
|
||
**Docs**: /uikit/uifontdescriptor, /uikit/uifontmetrics, /swiftui/font
|