Sift
Swipe your screenshots into three piles.
The app shows one screenshot at a time, newest unreviewed first. Left trashes, right archives, up favourites — and one rewind undoes the last call. Everything on this page comes from the Swift token files; the block you are reading is the onboarding block, tomato with navy ink.
Principles
Global rules, in force on every screen: no borders (the focus ring is the one exception) · no emoji · no icon tiles · no color-only meaning · no dark mode.
The screenshot is the hero
The card is the product. Chrome stays out of its way: a counter, two icon buttons, a verdict row. No tab bar, no toolbars, no second layout system.
Three verbs, three colors, never color alone
Trash, Archive, Fave. Direction, glyph and word carry the meaning; hue is the third channel, so the system still works for a deuteranope and in a screenshot.
One queue, one step back
A single queue, newest unreviewed first, and exactly one level of undo. Deeper mistakes are handled by Trash restore and by moving items in Library.
Destruction is confirmed by the system
Verdicts are reversible and need no confirmation. Permanent deletion always ends in the iOS dialog; deleting everything adds an in-app sheet that names the count first. The size rides on the docked button that opens it, not in the sheet.
Saturation belongs to verdicts and blocks
Chrome is navy on white. The only saturated things on screen are the three verdict colors and the expressive blocks behind onboarding and empty states.
Typeset, don't draw
Illustration is one line of type on a color block: round brackets for the head, one letter from a world script for the face. No image assets, no mascots, nothing to redraw when a string changes.
Color
Every value is one fixed OKLCH literal in DSColor.swift; the hex below and the contrast table are generated from it. The app is light only: canvas and surface are both pure white so a screenshot is never tinted.
Verdicts — main / on / soft
fave.main edge rows in the table below print as informational rather than as a failure.Ink
Surfaces and accent
accent / onAccent
The accent is the ink. Primary buttons are navy pills with white labels. Saturation in the chrome is limited to four places: the three stamps, the three verdict buttons, the Trash count badge, and the destructive fill (ADR-005). A color block is not chrome.
focus
Ink at 60 %, 3 pt, outside the shape. The only line the system draws. It was 45 % until that composite measured 2.79:1 on canvas, under the 3:1 non-text bar.
Focused control
scrim · shadowNear · shadowFar
Scrim dims what is behind a sheet at 35 %. The two shadow tokens compose --shadow-1 and --shadow-stack — nothing else casts.
Expressive palette — blocks only, never verdicts
mint
Trash empty
#7cd2ae
violet
Permission denied · white ink
#5849b2
lime
Queue finished
#e0fc41
pink
Favorites empty
#f6c1e9
lavender
Archive empty
#b98cea
Two blocks borrow from elsewhere: onboarding is trash.main (the reference hero is tomato, and onboarding precedes any verdict) and the limited-access interstitial is fave.soft. Nothing runs the other way — an expressive color never means a verdict.
Measured contrast — 43 pairs, 0 failures
| # | Foreground | Background | Use | Min | Ratio | Result |
|---|---|---|---|---|---|---|
| 1 | ink | canvas | text | 4.5 | 16.16 | PASS |
| 2 | ink | surface | text | 4.5 | 16.16 | PASS |
| 3 | ink | surfaceRaised | text | 4.5 | 13.35 | PASS |
| 4 | ink2 | canvas | text | 4.5 | 7.49 | PASS |
| 5 | ink2 | surface | text | 4.5 | 7.49 | PASS |
| 6 | ink2 | surfaceRaised | text | 4.5 | 6.19 | PASS |
| 7 | inkMuted | canvas | text | 4.5 | 5.77 | PASS |
| 8 | inkMuted | surface | text | 4.5 | 5.77 | PASS |
| 9 | inkMuted | surfaceRaised | text | 4.5 | 4.77 | PASS |
| 10 | onToast | toast | text | 4.5 | 16.16 | PASS |
| 11 | onAccent | accent | text · primary button label | 4.5 | 16.16 | PASS |
| 12 | onImage | viewerBackdrop | text · viewer bars | 4.5 | 21.00 | PASS |
| 13 | ink | chip over white | text · chip over a white screenshot | 4.5 | 16.16 | PASS |
| 14 | ink | chip over black | text · chip over a black screenshot | 4.5 | 12.89 | PASS |
| 15 | ink | surfaceRaised | non-text · secondary button glyph | 3 | 13.35 | PASS |
| 16 | focus | canvas over canvas | non-text · focus ring on canvas | 3 | 4.34 | PASS |
| 17 | focus | surfaceRaised over surfaceRaised | non-text · focus ring on a raised control | 3 | 4.06 | PASS |
| 18 | accent | canvas | non-text · primary button edge on canvas | 3 | 16.16 | PASS |
| 19 | onAccent | ink | text · CTA pill label on a block, the pill being the block's ink | 4.5 | 16.16 | PASS |
| 20 | accent | onAccent | text · CTA pill label on the denied block, the pill being white | 4.5 | 16.16 | PASS |
| 21 | ink2 | canvas | non-text · chrome icons | 3 | 7.49 | PASS |
| 22 | trash.on | trash.main | large text · stamp word (32 pt) and button glyph on main | 3 | 3.51 | PASS |
| 23 | ink | trash.soft | text · chip on soft | 4.5 | 13.01 | PASS |
| 24 | archive.on | archive.main | large text · stamp word (32 pt) and button glyph on main | 3 | 6.57 | PASS |
| 25 | ink | archive.soft | text · chip on soft | 4.5 | 13.12 | PASS |
| 26 | fave.on | fave.main | large text · stamp word (32 pt) and button glyph on main | 3 | 10.20 | PASS |
| 27 | ink | fave.soft | text · chip on soft | 4.5 | 14.01 | PASS |
| 28 | trash.main | canvas | non-text · button / badge edge on canvas | 3 | 3.51 | PASS |
| 29 | trash.main | white | non-text · stamp pill on a white screenshot | 3 | 3.51 | PASS |
| 30 | archive.main | canvas | non-text · button / badge edge on canvas | 3 | 6.57 | PASS |
| 31 | archive.main | white | non-text · stamp pill on a white screenshot | 3 | 6.57 | PASS |
| 32 | fave.main | canvas | informational · yellow edge is exempt, the navy word carries meaning (ADR-004) | — | 1.58 | info |
| 33 | fave.main | white | informational · same exemption on a white screenshot | — | 1.58 | info |
| 34 | ink | trash.main | text · destructive button label (16 pt bold) and onboarding copy on tomato | 4.5 | 4.61 | PASS |
| 35 | archive.on | archive.main | text · white label on a cobalt fill | 4.5 | 6.57 | PASS |
| 36 | ink | fave.main | text · copy on a yellow block | 4.5 | 10.20 | PASS |
| 37 | ink | mint | text · copy on the mint block | 4.5 | 9.02 | PASS |
| 38 | ink | lime | text · copy on the lime block | 4.5 | 13.99 | PASS |
| 39 | ink | pink | text · copy on the pink block | 4.5 | 10.56 | PASS |
| 40 | ink | lavender | text · copy on the lavender block | 4.5 | 6.18 | PASS |
| 41 | onAccent | violet | text · white copy on the violet block | 4.5 | 6.95 | PASS |
| 42 | onAccent | trash.main | large text · white stamp word on tomato — never a button label | 3 | 3.51 | PASS |
| 43 | surfaceRaised | canvas | informational · raised fill vs canvas (tint separation, no border) | — | 1.21 | info |
Typography
Two voices. Forager Bold Overlap is the display face, at its single weight of 700 — wordmark, display, headline, title and stamp all sit on it. Pretendard is the text face — Regular, Medium, SemiBold, Bold. Eleven roles, mirroring DSTextRole. Ten bind to a Dynamic Type style through relativeTo:; stamp is the exception, returning a fixed-size font so its 44 pt cap cannot be scaled twice, with the call site owning @ScaledMetric.
.verdict-cap, the string itself is title caseThe license note
Forager (Mark Simonson Studio) ships through Adobe Fonts, whose license covers web and desktop but not embedding in a mobile app. Until an app license is bought, the app declares the face and resolves it to Pretendard Bold at runtime — DSFont.display probes UIFont(name:"Forager-BoldOverlap") once per cut. This page loads it from Typekit kit blt4ith (family forager-overlap), which carries 400 and 700 — there is no heavier cut, so every display role is set at 700 rather than the 900 the previous face allowed. Without the kit the page renders the same Pretendard fallback, so nothing silently changes shape (ADR-003).
Caption is never a rung above body
Hierarchy moves size and weight in the same direction. A smaller-but-bolder line reads as no hierarchy at all, and at accessibility text sizes the two styles converge — so caption stays under body, never over it. Data digits (counter, sizes, dates, D-numbers) are tabular so the glyphs never jitter.
13 / 340
Spacing & Radius
Spacing is 6 · 8 · 12 · 16 · 24 · 28 · 32, inherited verbatim from the previous system (ADR-018). Radius is not a fixed decoration: it tracks the element's inner padding.
Spacing scale — DSSpace
Radius — DSRadius
xs 6) sitting 4 pt inside a card corner of md 12 looks wrong at 12 and right at 8. The screenshot card is xl 24 because its inner padding is 24; the stamp on it is a pill because its own padding is 8×16. Radius follows padding, not taste.Icons
Twelve glyphs, bold and solid, sourced from the Noun Project on the free plan. They are template images that inherit currentColor; there is never a filled tile behind a glyph. Until an asset lands in the catalog, DSIconRef.image falls back to the listed SF Symbol — development scaffolding, not a shipping state (ADR-017).
Credits are not optional
The Noun Project's free plan licenses icons under CC BY, so every shipped glyph owes a credit line. The app carries a credits screen fed by DSIconCredits.entries, reachable from the onboarding footer; while that array is empty the screen says no third-party icons ship yet. One entry per downloaded icon, in exactly this shape:
The twelve glyphs above are placeholders drawn to the same bold-solid brief so the layouts are real; each one is replaced by its Noun Project export plus its credit line before the app ships.
Emphasis comes from the glyph's own color, size and surrounding space. The icon sits directly on the surface.
No colored square or rounded tile behind a glyph. A tile invents a surface the system does not have and competes with the verdict colors.
Elevation & Layering
Borders are forbidden; the focus ring is the only line the system draws. Surfaces separate by tint and by whitespace. Shadows exist only for things that float — the front card, a committed stamp, the verdict buttons, the toast, sheets — and are two soft layers at 10 % and 5 % of the ink.
Flat — the default
No shadow. This card separates from the panel behind it by tint alone, which is how every list row, chip and segment works.
Floating — --shadow-1
0 1px 3px shadowNear + 0 10px 24px -6px shadowFar, cast downward. DSShadow.floating.
Stacked — --shadow-stack
The same two layers cast upward, so a card reads as lying on the one below it. DSShadow.stack.
In SwiftUI every shadow goes through .dsShadow(_:), which applies compositingGroup() first — otherwise each child is shadowed separately and a card grows a halo.
Layering — DSLayer
Interaction & Motion
Only transform and opacity animate; transition: all is banned. Entries ease out, exits are faster, and every animation is gated on Reduce Motion through DSMotion.gated(_:reduce:). Press is scale(0.90) on every button — press one below.
Buttons
Primary is a navy pill with a white label — the accent is the ink. Secondary is surfaceRaised with ink. Destructive is tomato with a navy label at 4.61:1. A 16 pt bold label is not WCAG large text, which starts at 19 pt bold, so white on tomato at 3.51 would not clear the 4.5 bar. It always carries the number. Tab to any of them for the ink focus ring.
The pill is never a free choice between navy and white. Filled with the block’s own ink it clears 4.61 against tomato at worst and 14.01 against pale yellow at best, and its label clears 16.16 either way. The retired wording would have put a white pill on lime at 1.15:1 and a navy pill on violet at 2.33:1 — invisible as shapes (ADR-023).
Chips on soft tints
Text on a soft tint is always ink, never main — a verdict color on its own tint is the one combination the table does not clear.
Verdict buttons — the gesture's second route
Solid main circles with the on glyph — 64 pt for the two side verdicts, 56 pt for Fave raised 8 pt, so the row reads as a direction map rather than a toolbar. Rewind is a 44 pt ghost pinned to the leading edge and drops to 45 % opacity when there is no history. Tapping runs the same exit pipeline as a swipe.
Motion tokens
Curves: enter cubic-bezier(.23, 1, .32, 1) · exit cubic-bezier(.32, 0, .67, 0) · standard cubic-bezier(.40, 0, .20, 1). Springs: snap-back 0.35/0.70, land 0.45/0.78, stamp pop 0.28/0.50, press 0.25/0.55. The throw's length is not a constant — it is clamp(720 / |v|, 0.18, 0.32), so momentum is never cut.
Toast and the destructive ladder
Toast
Navy capsule, white text, top-anchored, no action button — the only undo is rewind. It auto-dismisses after 2.5 s and always sits at the top of the z ladder.
Not deletedDelete 27 screenshots permanently?
They leave Sift for good and go to Photos' Recently Deleted for 30 days. iOS will double-check — that's expected.
Rung 0 (verdicts, viewer trash, library moves, restore) is reversible and asks nothing. Rung 1 (one item, permanently) is confirmed by the iOS dialog alone. Rung 2 (everything) adds the sheet above, which names the count and the size and pre-announces the second dialog, then hands over to iOS. The scrim behind it is ink at 35 %.
Haptics — DSHaptic
| Event | Feedback | Why |
|---|---|---|
thresholdArmed | .selection | Rising edge only, the moment releasing would commit |
trash | .impact(weight: .heavy) | A thud into the bin — the heaviest of the three |
archive | .impact(weight: .medium) | Weight encodes direction, so eyes-off swiping still confirms |
fave | .impact(weight: .light) × 2, 90 ms apart | A heartbeat; the only repeating pulse in the vocabulary |
rewind | .impact(weight: .light) | Undo is a verdict too, just a quiet one |
restore | .success | Something came back intact |
purgeArmed | .warning | Fired just before the iOS delete dialog appears |
purgeDone | .success | The deletion actually went through |
queueDone | .success | Inbox zero |
permissionGranted | .success | The library opened |
segment | .selection | Favorites / Archive switch |
error | .error | Nothing else in the system is red, so this carries it |
Snap-back and the down dead zone are silent — a non-event should not buzz. There is no sound in v1: with no settings screen, a sound that cannot be turned off would be a defect.
Swipe Deck
The product, running. This deck implements DSSwipe and DSMotion exactly as the app will: four 90° sectors with 6° of hysteresis, a commit at 120 pt or 800 pt/s, a velocity-derived throw, and promotion at 60 % of the exit. Drag a card, press a verdict button, or focus the phone and use ← → ↑ and Z.
What the gesture says
Stamps are pills: TRASH top-right at +12°, ARCHIVE top-left at −12°, FAVE centred at 0°, each with the verdict glyph and the word in the display face. Their opacity ramp ends exactly at the commit distance, so a fully opaque stamp is the "release will commit" signal. Solid fill, not outline: screenshots are usually white and an outline vanishes — the fourth placeholder in this deck is pure white to prove it, and the fifth is near-black.
With Reduce Motion on, the throw is replaced by a cross-fade: the stamp holds for DSMotion.stampHoldReduced so the verdict is readable, then the card fades out over DSMotion.crossFade. Drag still moves the card one-to-one — direct manipulation is not decoration.
Sectors — θ = atan2(−dy, dx)
A sector only changes when θ crosses 6° into the neighbour, so a finger resting on a boundary cannot flicker between two verdicts.
The numbers — DSSwipe and the stack
| Token | Value | What it decides |
|---|---|---|
commitDistance | 120 pt | Travel past which release commits. About a third of the card. |
commitVelocity | 800 pt/s | Velocity along the sector axis that commits on its own — a deliberate flick. |
minTravelForVelocity | 48 pt | Floor under the velocity rule, so a fast tap-drag cannot throw a card. |
rotationDivisor | 20 | Rotation = dx / 20, anchored near the bottom of the card. |
maxRotationDegrees | 12° | Clamp on that rotation in both directions. |
stampRevealStart | 20 pt | Where the stamp starts to appear — after the slop, not before. |
stampRevealEnd | 120 pt | Full opacity, deliberately equal to commitDistance. |
sectorHysteresisDegrees | 6° | How far into a neighbour θ must cross before the sector flips. |
exitDistanceX | 720 pt | Horizontal throw distance for TRASH and ARCHIVE. |
exitDistanceY | 840 pt | Vertical throw distance for FAVE. |
exitRotationDegrees | 18° | Rotation the card lands on as it leaves. |
downFollow | 0.35 | Rubber-band factor in the dead zone. Never commits. |
upScale | 1.03 | How far a FAVE drag lifts the card by the commit distance. |
promoteAt | 0.6 | Fraction of the exit at which the side effect fires and the next card is promoted. |
stackOffsetY · stackScaleStep · stackDepth | 14 pt · 0.06 · 3 | Cards behind sit at scale 0.94 / 0.88 and y +14 / +28; three are alive at a time. The second card interpolates toward the front as the drag progresses. |
exitDuration(velocity:) | 0.18–0.32 s | clamp(720 / |v|, exitMin, exitMax) — a hard flick leaves in 0.18 s, a threshold crawl in 0.32 s. |
Screens
Four screens, and that is the whole app: Permission, Review, Trash, Library. No settings, no statistics, no AI. Review is the root; Trash and Library are icon buttons in its header that push onto a navigation stack — a tab bar would take 83 pt away from the card and imply three equal destinations.
Sift your screenshots.
← TRASH
→ ARCHIVE
↑ FAVE
Icons
PermissionTomato block, typographic face, navy pill. The library is requested on tap, never on launch.
ReviewCounter, two chrome icons, the card, the verdict row. Everything else is the screenshot.
TrashA three-column holding pen. One item or all — no multi-select. Both routes end in the iOS dialog.
LibraryTwo segments over the same grid. Favorites is the Photos heart; Archive is the app's own list, mirrored to an album the app creates.
onImage text.
ArchiveTrash
Color blocks — DSBlock and DSFace
A face is one line: a pair of round brackets in the display face for the head, and a single letter borrowed from a world script doing the eyes and the mouth at once. Seven blocks, seven scripts, no letter twice — Naskapi, Tamil, Katakana, Greek, Telugu, Kannada and Cyrillic, each listed with its code point in DSFace.origin. Only the brackets are Forager; every centre glyph is drawn by the system font that carries its script, and that weight contrast is the intended look. A face is decorative and hidden from VoiceOver; the headline under it carries the meaning. Violet is the only block that takes white ink, at 6.95:1. Six blocks, one per DSBlock case besides onboarding: the limited-access interstitial borrows fave.soft, the second of the two exceptions ADR-016 records.
Grid & Responsive
One breakpoint, 1200 px. Above it, the desktop grid: 14 columns where the two ends are fluid margins and content lives in 2–13, gutter 24. Below it, the mobile grid: 4 columns, margin 16, gutter 8. The app only ever uses the mobile grid; this guide is the thing that uses the desktop one.
Desktop — 14 columns, ends are margins
Mobile — 4 columns, margin 16, gutter 8
Checklist
Run this before a screen leaves your hands.
- Every color comes from a token — no literal hex outside
DesignSystem/, and no opacity used to invent a text color. - On a verdict
mainfill only the 32 pt stamp word and the glyphs useon; every smaller label isink, and text on asofttint isink. - Nothing depends on the edge of
fave.main— the yellow exemption is acknowledged wherever yellow appears. - No borders anywhere. The focus ring is the only line, and surfaces separate by tint and whitespace.
- Shadows appear only on things that float — front card, committed stamp, verdict buttons, toast, sheets — and go through
.dsShadow(_:). - Radius tracks the element's inner padding, and a nested radius is the outer radius minus the gap.
- Type uses only the eleven
DSTextRoleroles — Forager Bold Overlap for display, Pretendard for text — andcaptionnever sits abovebody. - Every hit target is at least 44 pt; the hit area is padded, never the glyph.
- Every animation is gated on Reduce Motion through
DSMotion.gated, and onlytransformandopacityanimate. - VoiceOver reads the front card as one element, with custom actions in the order Trash → Archive → Favorite → Rewind last.
- Every shipped Noun Project glyph has its CC BY line in
DSIconCredits.entries, and no glyph sits inside a filled tile. - All four verification commands are green:
ds_tokens.py contrast,ds_tokens.py emit-css,ds_tokens.py lint,typecheck-ds.sh.