QuranFlow — Sakīna Design System
Reference for the visual language of the redesigned app, extracted from the working
mockup at ../mockup/. Source of truth: src/sak-base.jsx (tokens, shared
components) and AUDIT.md (locked conventions, the 8 binding rules). This document
restates both for implementation — every value below is copied from the code, not
invented.
Direction name: Sakīna ("stillness/tranquility"). Style label used internally: Manuscript-Editorial.
1. Design principles
The Sakīna direction blends manuscript typography (large serif display type, a verse
closer, one gold accent) with editorial restraint (content-first hierarchy, reduced
chrome, one dark "feature card" per screen). From AUDIT.md:
- Type-led. Large Newsreader display headlines are the primary anchor on every screen; body copy is sans-serif. Hierarchy comes from type scale, not from added chrome.
- Reduced chrome. No decorative ribbons, no vibes-only status pills ("On track"), no folio marks — unless the element earns its line by carrying unique information.
- Gold thread is precious. One flourish or one gold (
warmBright) accent per screen, never more. Gold means "the one thing that matters here" (a live indicator, a hairline catch-light, a primary action). - Ink is the brand moment. The near-black
inktoken is reserved for a single feature card per screen — Today's lesson hero, the You tab's coach card, the Live Class full-screen background. It is not the default hero treatment on every screen. - No monospace anywhere. Numbers — times, durations, dates, counts — are the sans (SF Pro) with tabular figures wherever they tick, align in columns, or sit opposite other numbers. Citations under Arabic are spaced small caps, sentence-case source text (e.g. "Al-Mulk 67:1–2" rather than "AL-MULK · 67:1"). Every uppercase label — eyebrows, section titles, chips, pills, tab labels — is sans-serif with letter-spacing. Captions and hints are plain sans sentences, never uppercase or tracked.
- Verse closer. Arabic + citation only, no English translation, and only on Today. English translation appears only on lesson-detail screens.
- Editorial structure. Masthead → feature → rail, repeated per screen.
- One editorial/brand line per screen (R8). Poetic or brand-voice copy (e.g. "Four sessions, one deadline.") is budgeted at one instance; a screen cannot have both a headline like this and a verse epigraph.
The 8 binding rules (build rules)
These are the enforceable checks; treat each as a lint rule when building or reviewing a screen.
| # | Rule | What it means when building |
|---|---|---|
| R0 | Progressive disclosure first | Default-collapse status, duration, counts, and body previews. Reveal behind a tap target, chevron, or "Show N more." Never render a full expanded state before the user asks. |
| R1 | One week reference per screen | SakStrip/eyebrow "Week N of 15" appears exactly once per screen — pick one slot (hero eyebrow or footer, not both). |
| R2 | One primary action per screen | Exactly one hero-weight CTA. All other actions render smaller/quieter below it. |
| R3 | No decorative pills | A pill must encode unique info (status, count, label). Delete any pill that only restates tone ("On track", "EST"). |
| R4 | No eyebrow + title when the title is self-explanatory | If the title alone identifies the screen/person (e.g. "Ustadha Amina"), drop the eyebrow above it. |
| R5 | No text restating a progress visual | If a SakStrip/SakDots already encodes "Week 8 of 15," do not also print that as a caption. |
| R6 | No section header for a single-item list | Section headers require ≥ 2 items below them, or a non-obvious grouping. |
| R7 | No instructional subtitle under self-evident UI | Don't caption a list of tappable rows with "Tap a row to edit." |
| R8 | At most one editorial/brand line per screen | See above — budget of one poetic/brand-voice line total. |
Build-time application of the loop in AUDIT.md: for any new screen, run Structure
(can the user answer "where am I / what can I do / where can I go" in under 2 seconds?),
Squint (exactly one visual anchor should survive defocusing), Prune (walk every
element against R0–R8), then Consistency (does it use SakNavRoot/SakNavDrill,
the locked eyebrow style, and the locked CTA pill shapes?).
2. Color tokens
Two palettes (Plum, Ink/Charcoal), each with a light mode (its own values) and
a shared dark-mode overlay (SAK_DARK, identical for both palettes). Resolved at
runtime by getSakTokens() in src/sak-base.jsx, which reads window.__sakTheme
({ palette: "plum" | "ink", mode: "light" | "dark" }) and merges SAK_DARK over the
palette's light values when mode === "dark".
Plum palette — light mode
| Token | Value | Role |
|---|---|---|
bg |
#FFFFFF |
Screen background |
paper |
#FBF7F2 |
Warm paper surface (passage cards, hero cards, closers) |
panel |
#F6F2F7 |
Recessed panel background |
card |
#FFFFFF |
Card surface (SakCard) |
ink |
#2A0B30 |
Brand-moment dark surface (hero cards, tab bar active pill) |
inkDeep |
#1F0824 |
Deeper ink variant (gradients, shadows) |
textStrong |
#1F0824 |
Primary text |
textBody |
#4A3F4F |
Secondary/body text |
textMuted |
#8C7C8E |
Tertiary text, eyebrows, muted labels |
rule |
rgba(42,11,48,0.10) |
Hairline borders |
ruleStrong |
rgba(42,11,48,0.18) |
Emphasized borders (secondary CTA outline) |
accent |
#650276 |
Primary action color (plum) |
accentDeep |
#4C0459 |
Accent gradient partner |
accentSoft |
#EADDEE |
Accent tint background (chips, pill fills) |
warm |
#E0A20A |
Gold — text/icon weight |
warmBright |
#FFB70B |
Gold — fill weight (CTA pills, gold hairline) |
warmInk |
#1F0824 |
Text/icon color on top of warm/warmBright |
warmSoft |
#FFF4D2 |
Gold tint background |
good |
#2E8B57 |
Success/positive status (green) |
danger |
#B91C1C |
Error/destructive status (red) |
Ink palette — light mode
| Token | Value | Role |
|---|---|---|
bg |
#FFFFFF |
Screen background |
paper |
#FAF8F4 |
Warm paper surface |
panel |
#F3F1ED |
Recessed panel background |
card |
#FFFFFF |
Card surface |
ink |
#1A1B20 |
Brand-moment dark surface |
inkDeep |
#0E0F12 |
Deeper ink variant |
textStrong |
#15171B |
Primary text |
textBody |
#43464D |
Secondary/body text |
textMuted |
#878A91 |
Tertiary text, eyebrows |
rule |
rgba(20,22,28,0.10) |
Hairline borders |
ruleStrong |
rgba(20,22,28,0.18) |
Emphasized borders |
accent |
#1A1B20 |
Primary action color (near-black, this palette has no purple) |
accentDeep |
#0E0F12 |
Accent gradient partner |
accentSoft |
#E5E3DE |
Accent tint background |
warm |
#C68A1E |
Gold — text/icon weight |
warmBright |
#E7A52A |
Gold — fill weight |
warmInk |
#0E0F12 |
Text/icon color on warm/warmBright |
warmSoft |
#FBEFCE |
Gold tint background |
good |
#2E8B57 |
Success/positive |
danger |
#B91C1C |
Error/destructive |
Dark mode overlay (SAK_DARK, applied to either palette)
| Token | Value | Role |
|---|---|---|
bg |
#171018 |
Screen background |
paper |
#1E1620 |
Warm paper surface |
panel |
#241B26 |
Recessed panel background |
card |
#211A24 |
Card surface |
ink |
#0D070F |
Brand-moment dark surface |
inkDeep |
#000000 |
Deeper ink variant |
textStrong |
#F6F2F7 |
Primary text |
textBody |
#CDBED1 |
Secondary/body text |
textMuted |
#8C7C8E |
Tertiary text, eyebrows |
rule |
rgba(255,255,255,0.10) |
Hairline borders |
ruleStrong |
rgba(255,255,255,0.18) |
Emphasized borders |
accent |
#C97DD6 |
Primary action color (lightened plum for dark bg) |
accentDeep |
#9D4FAA |
Accent gradient partner |
accentSoft |
rgba(201,125,214,0.18) |
Accent tint background |
warm |
#FFC93C |
Gold — text/icon weight |
warmBright |
#FFD55C |
Gold — fill weight |
warmInk |
#1F0824 |
Text/icon color on warm/warmBright |
warmSoft |
rgba(255,213,92,0.18) |
Gold tint background |
good |
#5DC08A |
Success/positive |
danger |
#E96A6A |
Error/destructive |
Note: dark mode replaces both palettes' surface/text/accent tokens with the same
SAK_DARK set — i.e. Plum-dark and Ink-dark render identically. good and danger
are the only tokens that don't vary between the two light palettes.
3. Typography
Two font stacks plus one Arabic face, declared in sak-base.jsx. There is no
monospace stack — DM Mono was removed in the Jul 2026 typography pass (see below).
| Variable | Stack | Used for |
|---|---|---|
sakDisplay |
'Newsreader', 'Fraunces', Georgia, serif |
Display headlines (h1s), hero numerals, meta-cell values |
sakSans |
-apple-system, BlinkMacSystemFont, "SF Pro Display", system-ui, sans-serif |
Everything else: body copy, all uppercase labels/eyebrows/chips/pills/tab labels, tabular-figure numbers (timestamps, durations, counts), and spaced-small-caps citations |
| — | 'Amiri Quran', serif (body), 'Amiri', serif (āyah-number glyph) |
Arabic Qur'anic text |
Locked size scale (Jul 2026 "typography quiet pass")
Half-step sizes (9.5, 10.5, 11.5, 12.5, 13.5, 14.5, 15.5) were collapsed onto this scale across 114 sites — do not introduce new sizes off this list:
| Size | Use |
|---|---|
| 10 | Smallest meta labels (MetaCell label) |
| 11 | Eyebrows, section titles, chips, pills, tab-bar labels, tabular-figure timestamps/citations |
| 12 | Secondary meta text, sans captions |
| 13 | Body copy, secondary CTA text, meta values |
| 15 | Primary CTA text, row titles |
| 16–17 | Nav-drill title (16), meta-cell display values (17) |
| 19 | SakNavRoot title |
| 22–34 | Display type: passage card Arabic (22), session/lesson h1 (26–30), Today hero h1 (34) |
"No monospace anywhere" rule
Prior to the Jul 2026 typography pass, uppercase labels were inconsistently set in
sakMono. A first pass locked every uppercase label — eyebrow, section title,
chip, pill, tab label — to sakSans, letterSpacing: 0.08em, fontWeight: 600
and carved out sakMono for numeric/technical values only (clock times, durations,
āyah citations, week counters shown as raw text). 86 label sites were converted
under that rule.
A second pass, later the same month, removed sakMono entirely — closing the
carve-out. Numbers now render in sakSans with fontVariantNumeric: 'tabular-nums' wherever they tick, align in columns, or sit opposite other
numbers (letter-spacing 0, not the old mono tracking). Citations under Arabic are
spaced small caps (fontVariantCaps: 'all-small-caps', letterSpacing: '0.1em',
fontWeight: 500, sentence-case source text, e.g. "Al-Mulk 67:1–2" rather than
"AL-MULK · 67:1"). Mono prose captions (e.g. "This review · logged automatically")
were rewritten as plain sans sentences.
Letter-spacing conventions
| Element | Spec |
|---|---|
Eyebrow (locked, AUDIT.md) |
fontFamily: sakSans, fontSize: 11, letterSpacing: "0.16em", textTransform: "uppercase", fontWeight: 700, color: t.textMuted (or t.accent for "current"/"now" state — at most one accent eyebrow per screen) |
| Section title | Same eyebrow style, paddingInline: 20, only rendered when ≥ 2 items follow (R6) |
Nav title (SakNavRoot) |
fontSize: 19, fontWeight: 700, letterSpacing: "-0.3px" |
Nav title (SakNavDrill) |
fontSize: 16, fontWeight: 700, letterSpacing: "-0.25px", centered |
| Hero h1 | fontFamily: sakDisplay, fontSize: 26–36, lineHeight: 1.08–1.15, letterSpacing: "-0.02em", fontWeight: 400 |
| Body | No explicit letter-spacing override (system default); no body italics |
Locked visual conventions
- Primary CTA pill: height 50–54px,
t.accentbackground,#ffftext, plum shadow0 6–8px 18–20px rgba(101,2,118,0.28). - Secondary CTA pill: height 40–46px, transparent background,
1px solid ${t.ruleStrong}border,t.textStrongtext. - Warm CTA (completion states, onboarding "Continue"):
t.warmBrightbackground,t.warmInktext, warm-tinted shadow (e.g.0 6px 14px rgba(255,183,11,0.32)). - Strip: always represents the full 15-week term —
<SakStrip total={15} done={7} current />— never a partial count. - Verse closer: Arabic + citation only; English translation reserved for lesson-detail screens.
4. Component inventory
All components below are defined in src/sak-base.jsx unless noted, and exported
onto window for use by every screen file. TweaksPanel (in tweaks-panel.jsx) is
a dev-only theme-switcher for the mockup canvas and is excluded from this inventory —
it ships with no production equivalent.
SakNavRoot({ eyebrow, title, action })
Top-level tab-screen header (Today, Learn, Sessions, You). Layout: eyebrow (11px,
0.16em, muted or accent) stacked above a left-aligned 19px/700 title, with an
optional 38px circular action (typically SakIconBtn) right-aligned at the
baseline. paddingTop: 58 (clears the status bar/Dynamic Island), paddingInline: 20, paddingBottom: 14. Use on the four root tabs only.
SakNavDrill({ eyebrow, title, action })
Drilldown-screen header (Lesson, Recording, You sub-pages, Session detail).
Layout: 38px circular back-chevron button (left) + centered eyebrow/title stack +
38px action or an empty 38px spacer (keeps the title mathematically centered).
paddingInline: 16, paddingBottom: 14. Title is 16px/700/centered; eyebrow 10px.
SakIconBtn({ icon, dot, onDark })
38×38px circular icon button. card background / rule border by default; on dark
surfaces (onDark) switches to translucent white fill + border. Optional dot renders
a 7px gold (warmBright) notification dot at the top-right corner, ringed in the
surface color so it reads as "sitting on top."
SakCard({ children, style })
Base card surface used throughout: card background, 0.5px solid ${rule} border,
borderRadius: 22, subtle shadow (0 1px 2px rgba(42,11,48,0.04)), overflow: hidden. Accepts a style override for one-off cases. This is the default container
for rows (session lists, meta rows, coach panels).
SakSectionTitle({ children, right })
Section label row: eyebrow-style label (11px, uppercase, muted, sans) left-aligned,
optional right slot (12px, sans, fontVariantNumeric: 'tabular-nums') for a trailing numeric/date value. paddingInline: 20, paddingBottom: 8. Per R6, only render when the section has ≥ 2 items below it.
SakStrip({ total, done, current })
Thin (4px tall) segmented progress bar — the canonical "week N of 15" visual. One
segment per week: filled accent for done weeks, warmBright for the current week
(if current is true), rule (hairline) for remaining weeks. Always called with
total={15} for the term-long strip (R1/R5 — this replaces "Week 8 of 15" captions
everywhere).
SakDots({ total, done, current, size, gap, doneColor, currentColor })
Row of small circles for short counts (e.g. onboarding step dots, attempts remaining).
Filled accent (or doneColor) for done, hollow-with-border for remaining, warmBright
(or currentColor) ring for the current item.
SakTabBar({ active }) / SakCoachTabBar({ active }) (student vs. coach app)
Floating glass tab bar, iOS-26 "liquid glass" treatment: heavy backdrop blur
(blur(32px) saturate(180%) brightness(1.08)), translucent white/dark fill
(rgba(255,255,255,0.58) light / rgba(36,27,38,0.55) dark), inset top highlight +
bottom shadow for depth, and a 1px gold hairline (warmBright at 0.55 opacity) across
the top edge — the one "gilt" detail tying the tab bar to the manuscript vocabulary.
Active tab renders as a solid ink pill with white icon/label; inactive tabs are
transparent with muted icon/label. Each tab is a stacked cell — icon over label, 19px
icon and a 10.5px/600 (student) or 11px/600 (coach) letter-spaced label. Student tabs:
Today / Learn / Sessions (Sessions label, calendar icon) / You. Coach tabs
(sak-coach.jsx): Queue (headphones) / Students / Schedule / You — same shell, no gold
hairline variance, slightly simpler shadow (no dark-mode-specific box-shadow branch).
One shell, two tab sets. The walkthrough renders both sets from a single component
(../walkthrough/app/src/app/TabBar.tsx): the set that renders is whichever one owns the
current tab, so coach mode is emergent from tab ownership rather than held as a mode flag.
All eight stacks exist from boot, entering the coach app is a re-root onto Queue and
leaving it a re-root onto You, and no chrome anywhere announces a "mode."
Count badge (Queue tab). A tab may carry a numeric count, and exactly one does: the
coach Queue tab shows how many submissions are waiting. Spec: a 16px-tall pill riding the
icon's top-right corner, sans with fontVariantNumeric: 'tabular-nums', warmBright
fill with warmInk digits when that tab is active (gold on the ink pill), ink fill with
white digits everywhere else. Never red. These are counts, not alarms — the app has no
red badge vocabulary, and a backlog a coach is already working through must not be drawn
as an error.
The badge is live, not decorative (Aug 3 2026): it reads the demo store, so sending the one review drops it 5 → 4 in the same beat the queue's headline and Sarah's row change. A count badge must never disagree with the list it counts — the queue headline counts its own rendered rows for the same reason.
Recorded divergence — the co-05 roster (logged Aug 3 2026). The Claude Design
source for the coach roster
(../walkthrough/build-notes/design-sources/co-05-roster.dc.html) draws
its active tab as a horizontal pill — ink-deep fill, borderRadius: 100,
padding: 10px 22px, icon and a 13px/700 label side by side. That is a divergence, not a
variant: the stacked treatment above is canonical, and the walkthrough follows it. Logged
here for the pending Design-project sync; do not propagate the horizontal form.
CTA pills (primary / secondary / warm)
Not standalone components — inline style blocks repeated per-screen against the
locked spec (Section 3). Primary = filled accent pill, 50–54px tall, plum shadow.
Secondary = outline-only pill (1px solid ruleStrong), 40–46px tall, no shadow. Warm
= filled warmBright pill with warmInk text, used for completion/onboarding
"Continue" actions. All are full or partial pill shape (borderRadius: 100).
Chips
Also inline, not componentized: small borderRadius: 4 (not full pill) uppercase
labels used as session "type" tags (QRC / Class / Hours), each with its own
tint pair defined in a local typeColors map (e.g. QRC = rgba(46,139,87,0.14) bg /
good fg; Class = accentSoft bg / accent fg; Hours = rgba(224,162,10,0.14) bg /
warm fg). Same map is reused in SessionRow (Today) and the Sessions tab so a chip
reads identically in both places.
SakPassageCard({ ayat, cite, compact, maxHeight }) (sak-feedback.jsx)
THE Qur'an-text component — every Arabic passage on the recording/feedback loop routes
through it. ayat shape: [{ num, segments: [{ text } | { text, mark: { n, tone } }] }].
Single āyah renders centered at 26px (or 22px if compact); multiple āyāt render as a
justified paragraph with āyah-end medallions (﴿n﴾ in warm, Amiri font) and scroll
internally past ~5 lines (maxHeight, default 240) so the screen never grows unbounded.
Word-level mark: { n, tone } renders PassMark — an underlined span with a small
numbered badge (warm or danger depending on tone) marking a specific correction
point. Card surface is paper with a top gold-hairline gradient accent and a cite
line below in spaced small caps (fontVariantCaps: 'all-small-caps', default "Al-Mulk 67:1").
SakVerseCard({ compact }) (sak-recording.jsx)
Thin wrapper that delegates to SakPassageCard — kept as the name used in the
recording flow for backward compatibility/clarity at call sites.
FbWave({ bars, played, height, activeColor, restColor }) (sak-feedback.jsx)
THE audio-waveform primitive, shared by the feedback ink card, recording review, and
voice-message bubbles. Renders bars (default 30) deterministically-seeded bars
(Math.sin-based pseudo-random, not Math.random, so the waveform is stable across
re-renders); bars before the played fraction render in activeColor (default
accent), the rest in restColor (default rgba(42,11,48,0.18)).
FbPlayBtn({ size, onInk }) (sak-feedback.jsx)
Circular play button, 40px default. Two variants: default (light surfaces) uses
accentSoft fill + plum-tinted border + accent play glyph; onInk variant (for use
on the dark ink feature card) uses translucent white fill/border + white play glyph.
Per REVIEW.md resolution D2, SakRecReview's previously-bespoke play circle was
replaced with this component so all playback affordances match.
SubStatusPill({ status, pov }) (sak-extras.jsx; walkthrough: ui/kit.tsx)
Renders one submission status via SakPill. pov chooses whose words the pill speaks
— 'student' (the default) or 'coach' — and it changes only the label, never the
color. This is the single source of the status vocabulary described in Section 5 —
always reference this component rather than re-deriving the label/color pairing, and
never hand-write a coach-side status label at a call site.
Recut Aug 18 2026 (U-435), and this is now the whole set: awaiting → "Awaiting
review" / "Needs review" (filled gold), inreview → "In review" in both seats (the
same gold as a 1px outline on transparent ground — further along, not a different kind
of thing), reviewed → "Feedback ready" / "Feedback sent" (plum), rejected →
"Returned" in both seats (gold at a deeper wash — it asks without accusing, and never
red), revoked → "Taken back" in both seats (panel ground, body ink — the quietest
chip in the set). Unsubmitted is the absence of a row, so it has no pill. The earlier
heard and practiced greens are retired: they described the student's habit, not
the submission, and whether a student has listened is a receipt on the feedback, not
a state of the recording. Neither word may reappear — a build assertion on the shipped
HTML fails if it does. The legacy review value normalizes to awaiting.
Session row anatomy (SessionRow in sak-core.jsx, TimelineRow in
sak-schedule.jsx — matching vocabulary)
Compact row (always visible): left time gutter (44px wide, left-aligned, no divider —
sakDisplay 18px time + sans 11px tabular AM/PM beneath), a type chip + optional "Live
now" indicator, title (15px/600) and "with {coach}" line (13px muted). Trailing
control is either a Join pill (live sessions — no expand affordance) or a chevron
(chevronRight/chevronDown depending on expanded state). Tapping a non-live row
toggles an inline expansion shelf (see Patterns, below) containing a coach mini-card,
a "What's covered" paragraph, and a primary "Open Zoom" + secondary "Remind me" CTA
pair. Live rows render the row in a faint accent-tinted background
(rgba(101,2,118,0.05)) and are not expandable.
`SessionDetailFrame({ title, chip, coach, coachInitial, time, ampm, duration,
when, location, primaryCTA, primaryIcon, children }) (sak-schedule.jsx) The canonical full-page session-detail shell, reused by SakSessionDetail(Fluency drills) andSakSessionDetailQRC(Sisters' Reading Circle) — adding a third session type is a content change against this frame, not a new design pass. Anatomy:SakNavDrill header → title block (chip + "when" label + Newsreader h1) → meta row (SakCardwith threeMetaCells: Time / Duration / Where, divided by MetaDiv hairlines) → "With" section (SakSectionTitle+ coach mini-card) →children` slot for
type-specific content (what-we-cover, cohort, materials) → primary/secondary CTA pair.
Expanding shelf (progressive disclosure pattern, not a standalone component)
Implemented identically in SessionRow (Today) and TimelineRow (Sessions tab) via
local React.useState — tapping a non-live row flips expanded, which conditionally
renders a bordered panel (borderTop: 0.5px solid rule, padding: 14px 14px 14px 60px — indented to align under the title, not the time gutter) containing the coach
mini-card, "What's covered" copy, and Open Zoom/Remind me actions. Both call sites are
described in AUDIT.md as intentionally sharing "the exact same expansion vocabulary"
so the two screens feel like the same component.
SakToggle({ on })
46×28px pill switch, reused in notification settings and the manage-subscription
screen. accent fill when on, rgba(120,110,130,0.25) when off; white 22px knob
slides between left: 3 and left: 21.
SakPill({ children, bg, color, style })
Generic pill-badge primitive underlying SubStatusPill and any other status/eyebrow
chip: paddingInline: 9, paddingBlock: 4, borderRadius: 100, 11px/600/uppercase
text, default accentSoft bg / accent fg, overridable via bg/color props.
SakFlourish({ inline })
The one-per-screen decorative gold-dot flourish: two 22px hairlines flanking a 4px
warmBright dot. Used sparingly per the "gold thread is precious" principle —
never more than once on a screen.
QFDevice / SakStatusBar
Device-chrome primitives (iPhone bezel, Dynamic Island, 9:41 status bar with
signal/wifi/battery glyphs, home indicator) used to frame every screen in the mockup
canvas. Not part of the shipped app's UI, but documents the safe-area assumptions
(paddingTop: 58 in nav headers accounts for the status bar + Dynamic Island).
Icon({ name, size, color, strokeWidth, fill, style })
Single SVG sprite sheet component covering every glyph used across Sakīna screens
(arrows, bell, book, calendar, camera, check, chevrons, clock, headphones, mic, play,
sparkles, user icons, video, x, etc.) — lucide-style, round linecap/linejoin,
strokeWidth default 1.8. New icons should be added to this single component's
paths map (in alphabetical blocks) rather than introduced as one-off inline SVGs.
5. Patterns
Timezone display
One banner per screen, never per-item suffixes. Per REVIEW.md resolution B3, prior per-row "EST"/"EDT" suffixes were removed app-wide. The locked pattern is a single line, once per screen that has times on it:
"All times shown in Toronto (EDT)"
(seen verbatim in sak-feedback.jsx's SakWeekAtGlance and sak-schedule.jsx's
Sessions tab). Coach-facing screens use the coach's own city/zone in the header
eyebrow instead (e.g. "Tue · 17 Apr · Cairo (EEST)" in SakCoachSchedule). The You
tab's timezone field is normalized to "Toronto · Eastern (EDT)". Do not reintroduce
per-row zone suffixes — that was an explicit R3-adjacent cut.
Progressive disclosure (R0)
The default state of any screen shows only what answers "what's next?" Three concrete mechanisms recur across the codebase:
- Inline expanding shelf — tap a row to reveal detail without navigating away (session rows; see Component inventory).
- "Show N more" / collapse-at-N —
SakFeedbackNotes({ notes, collapseAt = 3 })truncates a notes list and exposes the rest behind a control. The mechanism stands for any long list; the five-note feedback list it was built for does not. A coach's point is now a word plus the short voice note she spoke onto it (PRODUCT-SPEC, corrected Aug 4 2026), so the shipped Submission — feedback ready shows both points uncollapsed — reach for this pattern where a list actually runs long, not to reinstate that one. - Scroll-inside-card —
SakPassageCardcaps multi-āyah passages atmaxHeight(240px default) and scrolls internally rather than growing the page.
Status vocabulary (submission/feedback loop)
Five states — recut Aug 18 2026 (U-435). Defined once in SubStatusPill
(sak-extras.jsx; the walkthrough's copy is ../walkthrough/app/src/ui/kit.tsx) and
reused everywhere a submission's state is shown. One status value, two seats: the pov
prop picks the label column, and both columns share the color.
| Status key | Student label (pov="student") |
Coach label (pov="coach") |
Color |
|---|---|---|---|
awaiting |
Awaiting review | Needs review | Gold (warmSoft #FFF4D2 bg / #8A6D00 fg) |
inreview |
In review | In review | Gold outline (transparent bg / #8A6D00 fg / rgba(138,109,0,0.32) border) |
reviewed |
Feedback ready | Feedback sent | Plum (accentSoft bg / accent fg) |
rejected |
Returned | Returned | Deep gold (rgba(224,162,10,0.22) bg / warmInk fg) |
revoked |
Taken back | Taken back | Muted (panel bg / textBody fg) |
Unsubmitted is the absence of a row, so it has no pill — a week with nothing sent says
"Not sent yet" in plain text. The retired heard and practiced greens described the
student's habit rather than the submission, and whether a student has listened is a
receipt on the feedback, not a state of the recording; neither word may reappear, and a
build assertion on the shipped HTML fails if either does. The legacy review value
normalizes to awaiting.
Colors keep the design canon set in the port pass (../reconciliation/FINAL-DESIGN-REVIEW.html:
"In review = gold, Feedback ready = plum, sentence case"), and they satisfy REVIEW.md
A2: waiting-on-the-coach and waiting-on-the-student must stay visually distinct — do
not collapse these to the same weight. The two waiting states share the gold thread
because from the student's side they are one moment of the week (sent, not answered),
separated only by whether the coach has it open; rejected sits deeper in the same
family because it is the one state that wants something back, and never red — red in
this app is only the recording dot and the LIVE dot. Neither waiting state carries
alarm styling (U-406). The flow is awaiting → inreview → reviewed, with rejected
and revoked as exits; a submitted recording always routes to a detail/pending screen
(SakSubmissionPending, which owns the take-back) rather than a dead-end confirmation.
One state, two seats — the perspective flip (logged Aug 3 2026; states recut Aug 18
2026). The two labels that differ are exactly the two that describe someone else's
next move. awaiting is work the coach owes and the student is waiting on; reviewed
is work the coach has finished and the student has yet to open. So each seat reads the
same fact from its own side: Sarah's Week 8 submission says "Awaiting review" to Sarah
and "Needs review" to Ustadha Amina, off one status value. The other three —
inreview, rejected, revoked — name a move whose author is unambiguous from either
chair, so they read identically to both and get no second label. The colors never
flip — a coach and a student looking at the same submission see the same swatch, so
the pill stays learnable when a coach crosses into her own student app
(coach-you.tsx → "Switch to your student app"). Gold means the week is still in
flight, plum means the note is waiting on the student. Coach-side surfaces carrying the
flip: the roster (coach-students.tsx) and the student record's submission history
(coach-student.tsx) — and because one of the coach labels names a move that isn't
hers, that record prints a plain-sans legend under the history card ("Feedback sent —
she has your note. Returned — it came back with your written reason, and it never
counts against that week. The receipt on each feedback row says whether she has played
it."). Student labels are unchanged.
Reminder bell affordance
Root-tab notification entry point is SakIconBtn icon="bell" dot in SakNavRoot's
action slot (seen on Today) — the 7px gold dot indicates unread notifications.
Per-session reminders use a secondary "Remind me" pill (calendarPlus icon) inside the
expanding shelf / session detail CTA row, never a bell — the bell is reserved for the
notification inbox, the calendar-plus icon for "add this specific event's reminder."
Empty states
Named/tracked states live in sak-states.jsx (SakTodayRest, SakProfileFirstWeek,
SakSubmissionsEmpty, SakNotifEmpty, SakRecPermission) and are treated as first-class
screens with their own anchor per the audit loop — not a single generic "nothing here"
placeholder reused everywhere. Each empty/edge state keeps the same nav header
(SakNavRoot/SakNavDrill) and tab bar as its populated counterpart so the app frame
never jumps.
6. Accessibility notes
- Fine/light fonts are a known problem. Stakeholder feedback on the prior mockup
(
FEEDBACK-SYNTHESIS.md) explicitly flags: "Reduce color variety, single theme. Accessibility: fine/light fonts hard to read." This is a standing constraint on Sakīna, not a resolved item — when adding new screens, avoidfontWeightbelow 500 for any body or label text, and avoid text below the locked 10–11px floor (Section- except for the smallest meta labels, which should stay short and high-contrast.
- Minimum sizes. Per the locked scale, nothing should render smaller than 10px
(
MetaCelllabel is the current floor). Eyebrows/labels sit at 11px as the practical minimum for anything a user is expected to read rather than glance past. - Contrast implications of the paper palette.
paper(#FBF7F2plum /#FAF8F4ink) andcard/bg(#FFFFFF) are close in value by design (a warm-white layering effect), which means borders rely on theruletoken (rgba(42,11,48,0.10)/rgba(20,22,28,0.10)) rather than fill contrast to separate surfaces — at 10% opacity these hairlines are already faint. Any new surface-on-surface composition (e.g. a card onpaperonbg) should be checked against this — don't rely solely on a 0.5px hairline where a genuinely low-vision user needs a value or color break.textMuted(#8C7C8Eplum /#878A91ink) againstbg/paper/cardis the site's built-in "already borderline" text color — do not introduce anything lighter thantextMutedfor text that must be read, and do not usetextMutedfor body copy of any length (its intended use is short eyebrows/timestamps only). - Dark mode is a straight token swap, not a redesign —
SAK_DARKprovides its own accent/warm/text values already tuned for a dark background (e.g.accentbecomes a lightened#C97DD6rather than the light-mode#650276, which would fail contrast on a dark surface). Any new component must resolve all colors throughgetSakTokens()rather than hardcoding a palette value, or it will silently break in dark mode.