Imported from Nextzzz/Strunika (
.claude/skills/strunika-ui/SKILL.md). Install upstream withnpx skills add Nextzzz/Strunika --skill strunika-ui. Copyright stays with the author.
Strunika UI — design system & rules
Strunika (from Ukrainian «струна», a string) is a guitarist's companion: tuner, live chord recognition while you play, chord recognition for a file / recording / YouTube link. Product target is iPhone; a Windows head of the same MAUI project is used for day-to-day UI iteration on a PC. Everything must look native-iOS on the phone and merely work on Windows.
Companion skills: apple-hig-designer (how iOS should look/behave),
apple-design (review against HIG), maui-* plugin skills (how to do it in
MAUI: maui-theming, maui-animations, maui-safe-area, maui-gestures,
maui-graphics-drawing, maui-custom-handlers, maui-platform-invoke,
maui-performance, ux-mobile).
1. Brand tokens (source of truth)
| Token | Hex | Role |
|---|---|---|
Gold (Accent1) |
#D9AC4C |
primary accent: active tab, the detected chord, in-tune state, primary buttons, waveform |
Copper (Accent2) |
#AE6F32 |
secondary accent: guitar body, secondary buttons, pressed/hover states, sliders track |
Cream (Accent3) |
#E9D3A2 |
primary text on dark; soft surfaces/highlights on light |
DarkBase |
#16110B |
dark-theme background (warm near-black, not neutral grey) |
LightBase |
#FBF3E3 |
light-theme background (warm parchment) |
Derived scale (define in Resources/Styles/Colors.xaml; use AppThemeBinding
via named Surface*/Text* keys, never raw hex in pages):
Dark theme Light theme
Bg #16110B (DarkBase) Bg #FBF3E3 (LightBase)
Surface1 #211A10 (cards) Surface1 #F3E8CF
Surface2 #2C2316 (elevated) Surface2 #E9D3A2 (Cream)
Separator #3A2E1C Separator #D9C6A0
TextPri #E9D3A2 (Cream) TextPri #16110B (DarkBase)
TextSec #A48F66 TextSec #66522F
Accent #D9AC4C (Gold) Accent #AE6F32 (Copper: strokes/icons/large text)
AccentTxt #D9AC4C AccentTxt #AE6F32 (the guitar colour, same as Accent: "Pro" and the string beside it are one colour by rule; small copper text on cream is 3.4:1 — keep such text ≥ 14 pt bold or use TextSec)
Fill #D9AC4C / on-fill #16110B Fill #AE6F32 / on-fill #FFF8EC (selected chips, current chord bead, primary buttons — the GUITAR colour in each theme: Gold+ink on dark, Copper+cream on light; rule of 2026-09-02, replaces the old gold-in-both)
Dim #7A6543 Dim #A08A5C (past chords — decorative only)
Accent2 #AE6F32 Accent2 #D9AC4C
Semantic states (tuner, confidence): in-tune / confirmed = Gold glow; flat/sharp
/ provisional = Copper or desaturated Cream at 60 % opacity; errors only use a
muted red (#C4533A) — never a saturated system red on this warm palette.
Verify contrast ≥ 4.5:1 for text (apple-design → color.md, dark-mode.md).
2. Look & feel — iOS-native in MAUI
- Typography: system font (SF Pro on iOS, Segoe on Windows). iOS text
styles: LargeTitle 34/bold, Title1 28, Title2 22, Headline 17/semibold,
Body 17, Subhead 15, Footnote 13, Caption 12. Chord names and tuner note are
display elements: 64–96 pt, weight Bold/Heavy, tight tracking. Support
Dynamic Type (
FontAutoScalingEnabled="True"). - Layout: 8-pt grid, 16-pt page margins, 44-pt minimum tap targets, safe
areas respected (
maui-safe-area). Cards: corner radius 16–20, no hard borders on dark — separate by surface tone; on light a 1-pxSeparator. - Navigation: Shell tab bar (SF Symbols on iOS via
Platforms/iOShandler /FontImageSource), large titles on root pages, sheets for secondary actions (model picker, options) — bottom sheet with grabber, not a full-screen modal. - Materials: translucent blur bars/panels on iOS through
UIVisualEffectViewin platform code (maui-platform-invoke); on Windows degrade to a solidSurface1at 92 % opacity. Never ship a look that depends on blur. - Motion: purposeful, short. Springs for state changes
(
Easing.SpringOut, 250–400 ms),CubicOutfor enters,CubicInfor exits. The tuner needle and live chord label animate continuously (≤ 16 ms per frame budget — useGraphicsView+Invalidate(), not per-frameTranslateTo). Honor Reduce Motion (UIAccessibility.IsReduceMotionEnabled). - Haptics:
HapticFeedback.Perform(Click)when the tuner locks in tune,LongPresswhen a new chord is confirmed live; no haptics on Windows. - Themes: dark and light are first-class; follow the system by default, with an override in settings. Test every screen in both.
- Windows head: acceptable to look "iOS on Windows"; do not add
WinUI-specific chrome. Platform-specific code lives under
Platforms/, the XAML stays shared.
3. Uniqueness rule (ChordAI is the functional reference only)
Feature parity with ChordAI (chords from song / YouTube / live, tuner) is the goal; the interface must not be recognisable as ChordAI. Do not reproduce its layouts, iconography, colour scheme, naming, onboarding copy or screen structure. Design from Strunika's own world instead: strings, wood and brass, the waveform-through-the-guitar logo motif, warm parchment/ink contrast. When in doubt, ask "would a ChordAI user say this is a re-skin?" — if yes, change the structure, not only the colours.
4. Assets
- App icon:
Resources/AppIcon/appicon.svg— Gold guitar (#D9AC4C) + Cream waveform (#F6E6BF) with dark keylines on the dark radial base and a soft gold halo; the artwork sits atscale(1.15)(≈65 pt side margins on the 1024 canvas). Copper on the dark base read as mud at icon size and the oldscale(0.92)left a strip a third of the height tall (2026-09-02). Resizetizer caches renders: after editing the SVG deleteobj/**/resizetizeror the old icon ships. One icon for both themes — the app icon does not follow the theme (decided 2026-09-02; iOS 18 appearance variants andSetAlternateIconNamewere considered and rejected — the latter pops a system alert on every switch). Formerlystrunika_guitar_bg.svg(copper guitar + gold waveform on dark radial gradient). For iOS the icon must be a full-bleed square with no transparency — iOS applies its own superellipse mask; strip the baked-in rounded rect before using it asMauiIcon. - In-app logo:
strunika_guitar.svg(no background) — crop its viewBox to the artwork before use so it scales predictably; use as splash/onboarding mark and empty-state illustration, tinted per theme. - Source files live in
C:\Users\taras\OneDrive\Рабочий стол\icons\; copies belong insrc/Strunika.Mobile/Resources/AppIconandResources/Images.
5. Decisions log (settled in the design interview, 2026-08-26)
🔒 = Pro feature. Treat every line as decided; anything not here is open — ask.
Platforms & policy
- iPhone-only (
UIDeviceFamily= 1), iOS 16+, portrait only. Windows head for dev. Android "maybe later": every iOS-specific piece has a neutral fallback,Platforms/Androidkept but not built. - No third-party analytics or crash SDK — "we collect no data". Apple's built-in crash reports only.
- Localisation uk + en via
.resx(Strings.resx,Strings.uk.resx); no hard-coded UI strings. - First launch: ONE screen — animated wave logo, language (with flag icons: UA / GB, drawn as vector, never emoji) + theme pickers pre-filled from the system, "Почати" → lands on the Tuner with a "play any string" hint. The tuner listens the moment it is on screen, so on a new install the microphone is asked for right after Welcome; Welcome says nothing about the microphone (user decision 2026-09-11).
Navigation: 4 tabs — Тюнер · Наживо · Пісні · Налаштування. NO icons in the nav bars of root pages (a gear read as a "sun" in mockups); theme + language live inside Settings.
- Tab bar = custom
PillTabBarcontrol, not the native Shell TabBar (Shell.TabBarIsVisible=False, tabs switched viaShell.Current.GoToAsync): a floating capsule (14 pt side margins, 28 pt above the screen's bottom edge on a phone with a home indicator — a little above the 21 pt iOS 26 keeps its own floating tab bar at, since dragging the selector along the bar was catching the home gesture (2026-09-14); 20 pt without one; content runs edge to edge underneath (SafeAreaEdgesbottom = None on the root, decision 2026-09-07) — 72 pt tall, radius 36,Surface1+ 1-px separator + soft shadow) holding 4 items; the active item sits in an oval Gold selector (52 pt tall, dark icon+label, glow). The selector is draggable: it slides left/right with the finger — pressed beside it, the selector comes to the finger the moment the drag begins (2026-09-14) — it snaps to the nearest item on release (spring, ~300 ms) and switches the tab; tap also works; light haptic on snap. Press feedback on the whole capsule: on touch-down the bar GROWS to ~1.04 (ScaleTo, ~120 ms,CubicOut); on release it springs back DOWN to 1.0 with a small undershoot (~0.99 → 1.0,Easing.SpringOut, ~350 ms). A plain tap therefore reads as a "bounce"; a drag keeps the bar enlarged until the finger lifts. Honor Reduce Motion (no overshoot, 150 ms fade instead). Root pages keep ≥116 pt bottom padding so content clears the floating bar. - Theme switch is instant (a snapshot cross-fade was tried on 2026-08-26 and read as "staged" on Windows — the user asked to revert it).
- The capsule casts no shadow (user decision 2026-08-26). Instead a bottom gradient zone (
BottomFade, ~100 pt: a few points above the bar + the bar + the gap below) fades transparent → Bg top-to-bottom, so scrolling content dims and slips under the bar. - Theme picker (first launch + Settings) shows three vector icons: half-filled circle = Системна, moon = Темна, sun = Світла.
Tuner (MainPage → rename TunerPage)
- Chromatic + tuning presets with 6 "peg" pips: Standard (free), Drop D, Half-step down, Full-step down, DADGAD, Open G, Open D, Ukulele GCEA, Bass EADG (🔒). Auto string detection, tap a peg to lock it.
- Smoothing (implemented in M1): attack detector (RMS jump > 3× envelope) → ignore 160 ms of the pluck, then re-seed; YIN clarity ≥ 0.8 gate; median of 5 → EMA (0.6/0.4) → slew 9 ¢ per 80 ms tick; in-tune = |ema| ≤ 6 ¢ for 2 ticks; clarity hysteresis (0.8 to appear, 0.6 to stay); 1.5 s hold when the pitch is lost. Mute detector: a damped string loses >18 dB almost at once (peak 10 ms RMS < 0.12× the pre-drop level for 3 consecutive 46 ms chunks ≈ 140 ms, only ≥300 ms after the attack); a freely decaying note never does. That, or real silence (level < max(0.001, 1.5× adaptive noise floor)), clears the reading at once. Bias = keep the sounding string (user decision 2026-08-26): a muted string may linger ~200 ms, but a ringing one must never vanish early — do NOT gate on level alone (a 4×-noise gate cut live notes on a real guitar), do not shorten the hold below ~1.5 s. The string is chosen once per pluck (median of the first 3 settled frames after an attack) and never changes while the note decays — user decision 2026-08-26. Pitch comes from
Strunika.Core.Analysis.TunerEngine(unit-tested): YIN + sub-harmonic evidence check (energy at f/2 and its odd multiples, relative to the loudest partial) so the weak fundamental of the low E is not mistaken for E3/E4; the string is the nearest by real pitch (fret 3 on low E = G2 → A string, fret 2 → E string), cents fold octaves only for the readout. Flat tunings spell notes with ♭. - Readout = points, not cents: 10 points = one fret (100 ¢); shown as sign + number only ("+3", "−2", "0"), large (52 pt) under the note, gold when in tune. No verdict words, no hints on the tuner screen.
- A string held in tune for 1.5 s is marked tuned (peg filled Gold). When the last peg is done: pegs bounce in a wave and the string pulses gold three times and every string twangs in turn — the microphone keeps listening. There is no Listen button (user decision 2026-09-11): the tuner listens while its tab is on screen and the app is in front. In silence the hero shows "● СЛУХАЮ" over "Давай затюнемо!" / "Let's tune!", or "Все в строї — поїхали!" / "All set — let's play!" once every string is tuned (it springs in); while a string sounds, the note and its points, cross-faded. Without a microphone the line says so, a grey line under it says a tap opens the access settings, and the tap does; coming back to the app tries again. The gold "Почати знову" / "Start again" chip (one wording in each language) under the tuning selector is the only way to forget the tuned strings; it shows once ≥1 string is tuned. A tuned peg that is being tuned again still shows the active state (dark ring + scale 1.12 on the gold fill).
- Leaving the tab or sending the app to the background stops the microphone and releases a locked string, keeping the tuned marks; coming back starts it again. Tapping the locked peg again unlocks it. Leaving the Live tab stops its microphone too.
- Indicator = the string: a horizontal line that sags/tightens with cents offset and snaps straight + gold flash (+
HapticFeedback.Click) when in tune. No audible beep. - A4 reference 430–450 Hz 🔒 (Settings). NO metronome on the tuner tab (user decision 2026-08-26); the metronome lives on Live and on the Song page ("клік у темпі пісні"). Peg labels: E₂ A D G B E₄ — octave digits only where the tuning has two strings of the same name.
Live (LivePage)
- Hero chord (display serif, smaller than on the Song page — ring ≈172 pt) + guitar diagram beside it (shared
ChordHero); confidence ring (grey while DSP guess → fills gold on neural confirm). - The live session is a recording. Below the hero: the same
StringTimelineas the Song page — the real audio waveform of the take with chord beads aligned to their positions; playhead = "now", the right half is empty (dotted baseline). Dragging the track or tapping a chord moves the playhead; NO audio while dragging, playback starts from there on release (live listening pauses while reviewing; "Слухати" resumes/starts a new take). No decorative reactive wave. - "Прості акорди" is ON by default; turning it OFF (full vocabulary) is 🔒 — the toggle carries a lock badge.
- Recognition modes shown with human names + hint, only when "Експертні налаштування" is on (release default off → auto model choice: live=base, guitar solo=guitar2, songs=self).
- Metronome available here too: clicks are short high-frequency ticks and their frames are masked out of the analysis; "headphones are more accurate" hint on first use. No iOS voice-processing/echo cancellation (it damages the guitar signal).
Songs (LibraryPage → SongPage)
- Library cards: thumbnail (YouTube) / source glyph, title, artist, key, tempo, duration, date. Search, sort (date/title/key), favourites — free. Folders/setlists 🔒. Swipe to delete. Empty state = wave logo + "Додай першу пісню".
- "+" in the nav bar → bottom sheet: Файл (Files/iCloud: mp3/m4a/wav) · Запис (mic, wave + timer, saved as m4a in app data) · YouTube (URL field; auto-fill from clipboard when it holds a YouTube URL). iOS Share Extension → v1.1 (needs macOS CI).
- Implemented in M2 (2026-08-26): library card = 56 pt thumbnail (YouTube jpg) or source glyph on an Accent2→Surface2 gradient tile, title 17 bold, subtitle (artist / "Recording · 26 Aug" in the app language / source), meta row
Keyin Display serif AccentText +♩ bpm · m:ss, star (Border-wrapped IconView — bare GraphicsView gets no taps on Windows), analysing card = 4 pt ProgressBar + "Analysing · 64 %" + × instead of the star, failed card = Error-coloured line, tap = retry. Recordings are WAV (not m4a) written byCore.Audio.WavFile— same code on both platforms. Sort = action sheet (Date / Title / Key). Cancelling an analysis of a song without a result removes the card at once (the worker cleans up later; CQT is not interruptible); cancelling a re-analysis keeps the old chords. Opening a card shows a summary alert until the Song page (M3) lands. Free-tier caption on the add sheet: "Free analyses left: N" / "Free: 1 analysis per day". - Ways of adding, revised (user decision 2026-08-26): the add sheet lists YouTube · File · Record, each with a ★; starred ways are also shown as a row of quick buttons right under the "Songs" title (default ★ = YouTube + File;
AppSettings.PinnedSources). One YouTube button: if the clipboard holds a YouTube link → a small choice sheet ("Add this link" in gold with the trimmed URL, or "Open built-in YouTube"); otherwise the built-in YouTube opens at once. Built-in YouTube =YouTubeBrowserPage(full-screen WebView on m.youtube.com; pollslocation.href, and an "Add" bar with the video title appears on any /watch page — the song is then added through the normal metadata + audio path). No URL text field anywhere — the clipboard is the way to paste. - Welcome greeting (2026-08-27): the WaveMark on the welcome screen is replaced by the user's own hand-lettered greeting drawn as a string —
Resources/Images/hello_uk.svg(«Привіт») for Ukrainian andhello_en.svg("Hello") for every other language, shown as anImage342×90 (Aspect=AspectFit, Margin 0,16,0,0), source switched live with the language (WelcomePage.ApplyGreeting). The SVGs are the user's traced artwork with the background removed via an SVG<mask>that keeps the trace's own layer order (gold shapes white, dark counters black, later gold over them) — merging the shapes into one path loses the counters and the dot on «і». Trace outline strokes are dropped; the lettering is filled with the brand Accent per theme (hello_*.svg= Gold #D9AC4C for dark,hello_*_light.svg= Copper #AE6F32 for light, picked withSetAppTheme) so it matches the wave exactly — the trace's own golds (#d1a544 / #d4aa4b) were replaced (user request 2026-08-27). Font-based scripts (Bad Script, Comforter, Caveat) were prototyped and dropped in favour of the custom lettering. - Welcome motion + sound (2026-08-27): the page fades in from the dark background over 0.5 s (the Launch→Welcome pop is not animated), then a left→right wipe (2.0 s, SinInOut) replaces it with the lettered greeting at the same x — the wave is drawn without glow,
Thickness=3.0, width 326 of 342, margin 13.75 top, and the greeting Image usesAspect=Fillat exactly 342×90 — so the two lines match in weight, length and height (verified by the user) (none of this under Reduce Motion) while a synthesized sound plays (Resources/Raw/sounds/greeting.wav, Karplus-Strong, generated in-repo — no third-party audio): the user chose «harmonic bells» — three natural 12th-fret harmonics E4 → B4 → E5, 320 ms apart, long ring, 3.0 s — over strums and bass-heavy arpeggios ("too much bass, wants something ringing", 2026-08-27). iOS plays it in the Ambient session category so the silent switch is respected. Settings → Appearance has "Skip the welcome screen" (default on); the first launch always shows Welcome. - Launch screen (2026-08-26):
LaunchPageopens every session — mark, "Strunika" in Display 46, the WaveMark breathing (opacity 1 → 0.45 → 1, 900 ms each way, off under Reduce Motion), Footnote caption at the bottom ("Tuning up…" / "Preparing recognition…" on first run). It does real work (model unpack) and hands over with the stack swap (InsertPageBefore+PopAsync). Windows head: ≥3 s so it can be reviewed. - Settings order: the Strunika Pro card sits first, right under the title, and carries a small WaveMark (26 pt) between its title row and the feature list; then Appearance · Tuner · Recognition · About.
- Pro title suffix (2026-08-27): when the user has Pro, the tab titles Tuner · Live · Songs read "Тюнер Pro" + the small wave (
ProSuffixcontrol: "Pro" in Display/AccentText at the title size, wave 54:20 of the font size — the same ratio as in Settings), bound toIsProon each view-model. - Song cards (2026-08-27): top-right corner carries a dim "‹ 🗑" hint (chevL 10 + trash 12, Dim, 70 %) — swipe left to delete. The Songs header (title, quick buttons, search, filters) sits above the list in the layout (rows
Auto,*), never over it — the list starts where the header ends, so no card can sit under it; a 28 pt shade at the top of the list row (Controls/ScrollShade, fed the list'sScrolledoffset; the chord dictionary uses the same) fades in with the first 28 pt of scrolling (like the hairline under an iOS large title) and is gone at rest. The list usesItemsUpdatingScrollMode=KeepScrollOffset(MAUI's default scrolls the first item into view on every add — on Windows it raced the app's own scroll and parked the first card under the header), the page is patched in place (remove / insert / move, neverClear+ re-add), and only a page turn, filter, sort or search scrolls to the top (ListReset). Decided 2026-09-08 after two rounds of header-spacer / content-inset fixes did not hold. - Paywall layout (2026-08-27): pinned header = "Strunika Pro" (Display 32) + the 78×29 wave after "Pro" (same wave-to-font ratio as the 54×20 wave at Title2 in Settings) + ×, over a shade that is the page background fading out (opaque to 62 %, gone at 96 pt) so scrolled content dissolves under it like under the tab bar; no big wave. The ScrollView spans the screen (24 pt inset inside the content) so the scroll indicator sits right of the content. Feature order: Unlimited songs · Chord Editor · Export · Alternative tunings · Full chord vocabulary · Transpose & capo, then the rest. Footer links: Restore purchases (Apple-required) · Promo code (App Store offer codes) · Terms.
- Paywall "Coming soon" block (2026-08-26): under the feature list, an eyebrow COMING SOON and one card with three rows, each with a small gold "Soon" pill: "Stems: solo, mute and your own track mix" (sliders icon), "Karaoke / backing track" (mic icon) and "Bass tabs" (wave icon), one-line hints in Caption. All come from the same Demucs stem-separation investment; keep it to these three lines — no per-feature promises (guitar minus is unreliable in 6-stem mode).
- Paywall entry points: a locked control opens the compact modal sheet; Settings → "Learn more" pushes the full sheet so it slides in from the right edge (user request 2026-08-26).
- Switches: off state must stay visible on both themes — knob
TextSec, trackSeparatorwith aDimstroke (neverBg, which vanishes). - YouTube: on-device extraction (YoutubeExplode) behind
ISongSource, "like ChordAI": never store/export the audio, graceful "YouTube тимчасово недоступний" state, never crash. Playback via the official IFrame embed in a WebView (position polled ~100 ms). Never ship playback of the extracted stream (that is plainly "play downloaded YouTube media"). Store risk (2026-08-27): on-device extraction sits under App Store guideline 5.2.3 ("save, convert, or download media from … YouTube") and the YouTube ToS; the app carries a remote kill switch (Services/RemoteFlags,flags.jsonat the repo root) so YouTube analysis can be turned off for every install without a release, the progress caption is a plain "Analysing · N %" (no "fetching/decoding audio"), and the fallback architecture is server-side analysis (Chordify model) reusing the same pipeline. Review notes must describe it as the official player plus on-device analysis of a transient buffer that is never stored or exported. - Next chord preview (user request 2026-08-26): while a song, recording or take plays, show the next chord with its diagram beside the current one, visibly de-emphasised (smaller, TextSec, ~60 % opacity); when it arrives it animates into the hero position (slide + scale, ~250 ms) and the following chord takes the preview slot. Same on Live playback.
- Song page layout: top half = ChordHero (huge chord + diagram, capo-aware shapes); bottom third = string timeline (chord segments as beads on a string, wave below, fixed centre playhead, ribbon scrolls; segment width ∝ duration, beat ticks) + transport (play, A–B, speed). YouTube player collapsed to a thumbnail strip above the string, expands on tap. Analysis runs in background with progress on the card and cancel.
- Free: simple chords (
Simplify), "click at song tempo" metronome, beat snapping (on by default). 🔒: transpose/capo (Transpose, diagram follows capo), speed 0.5×–1.25× pitch-preserved (AVPlayer rate / YouTubeplaybackRate), A–B loop, export TXT / PDF / XLSX (MiniExcel; row = segment: start, end, bar, chord) / share sheet, Chord Editor (uk: «Редактор акордів», user decision 2026-08-27). - Chord Editor (name is literally "Chord Editor" in both languages; hint "редактор акордів"). Free for the first 3 songs (lifetime counter in Keychain, shown as "Безкоштовно · пісня 2 з 3"), then 🔒. Must feel first-class: own pushed screen (Скасувати / Готово), selected segment with edge handles, root · quality · bass pill rows, action row (split / merge / insert / delete), undo/redo, loop-audition toggle + play. tap a segment → root · quality · bass wheel; drag segment edges snapping to the beat grid (long-press = free); split / merge / insert / delete; nudge beats; undo/redo; loop-audition the segment. Edited songs get a "правлено вручну" badge; re-analysis asks before overwriting; export uses edited chords.
- Chord diagrams: guitar only in v1, own JSON shape DB (24 triads + 7ths/sus, 2–4 positions each), left-handed mirror free (Settings), capo-aware.
- Implemented in M3 (2026-08-27, redesigned the same day at the user's request): song page = header (back "‹ Пісні", title + source, "Редактор"/"Editor" chip → coming soon) · chip row (Тон. · ♩ bpm · 4/4 · Капо 🔒 · speed 🔒 · ✓ Прості, horizontally scrollable) · now/next panel (current chord Display 74 + 88×112 diagram, arrow, next chord 30 + 56×72 diagram at 66 %) · the conveyor · position slider · transport (metronome round button · « » = previous/next chord · ▶ 70 pt · A–B 🔒). At the song's end the play button shows a replay arrow (2026-09-15); the tap starts the song over as before.
- The conveyor (
ChordTrack) replaced the bead timeline: 208 pt tall, the waveform is the background (peaks stored per song, played part in the Accent, the rest neutral) drawn as grouped bars (5 pt wide, 2.5 pt apart) whose heights are precomputed on a grid anchored to the song, so a bar never changes height while it scrolls; each chord is a marker at the moment it starts — a 3 pt line inTextSec(never the accent — it must not read as a second playhead) with a 34 pt pill centred on that line, not a bar spanning its duration; the playing chord's pill sticks to the playhead and the next one is pinned to the right edge until it scrolls into frame; beat ticks under the wave; the playhead is a 4 pt Accent bar (no arrow) at 34 % of the width, so most of the track is what is still to come. Tap a pill = seek to that chord, tap the track = seek there, drag = silent scrub; a fling coasts on and slows to a stop like a scroll view (in the editor it moves the window, outside it the scrub goes on and the song is sought where the track comes to rest), a finger on a coasting track stops it the moment it touches (PointerDrag.Pressed), so does the map, and Play on a coasting track settles it first (2026-09-15). Whether a drag begins a scrub is decided by whether one is under way (_scrubbing), never by a flag left over from an earlier stop — a stale one let a drag run without a scrub while the song played, and the track stood still with the chords flickering (2026-09-15). Entering or leaving the editor ends whatever the finger or a coast had under way — a scrub that ran on into the editor was never ended and the song, paused for it, never played again (2026-09-15). Smoothness rules learned the hard way: the page drives it from the platform frame ticker (Animation.Commit, i.e. vsync) with a predicted position (transport probed every 200 ms);Positionis assigned directly, not through a binding (and the control must never assign its ownPositionduring a pan — a manual set clears the one-way binding and the track freezes after the first drag); label widths are cached; the nativeSlideris nudged only every 6th frame. - Touch targets (2026-08-27): the song page's transport follows the platform minimums — Apple HIG 44 pt, Material 48 dp — with the primary controls comfortably above them: play 78, chord skip 60, metronome 56, A–B chip 46, info chips 38.
- Chord diagrams: fret numbers are drawn inside the diagram, each centred on its own string (
ShowFrets) — the old row of numbers under the box did not line up with the strings. Tapping any diagram pauses the song and opensChordShapesSheetwith every position for that chord (chosen one outlined in the Accent, caption "Відкрита позиція" / "Лад N"); the choice sticks to that chord until capo, transposition or "simple chords" changes.
Pro / monetisation
- Auto-renewable subscription, monthly + yearly, no free trial. App Store Offer Codes for gifting (button "Ввести код" →
presentCodeRedemptionSheet); TestFlight before release. - Free song analyses: 20 lifetime, then 1 per calendar day (device local time). Re-analysing the same song doesn't count; already-analysed songs stay open forever. Counter + last-free-date in Keychain (
SecureStorage). IProGatewith sources: StoreKit entitlement (offer codes arrive here) OR dev override (Debug/TestFlight only; always on in the Windows head — no StoreKit there). UI asks onlyPro.Has(Feature.X).- Locked controls stay visible with a small gold lock badge; tap → compact half-sheet (feature name, price, CTA, "Усі можливості Pro"). Full paywall (wave hero, feature list, Year highlighted with "−N %", Month, Restore, Redeem, Terms) from Settings and after the song limit. Prices always from StoreKit, never hard-coded.
Settings (4th tab; iOS inset-grouped list): Вигляд (theme Системна/Темна/Світла, language with flag, left-handed) · Тюнер (A4 🔒, default tuning) · Розпізнавання (beat snapping, Експертні налаштування toggle → model pickers, key prior, YouTube audio-stream playback) · Strunika Pro card (feature line, "Дізнатися більше", restore purchases, redeem code) · Про застосунок (version, privacy + licences: display font OFL, models, YoutubeExplode).
Design
- Typography: SF Pro for all UI; a warm display serif for chord names, tuner note and large titles — mockups use Young Serif (OFL; Fraunces was dropped as an over-used AI default). Verify ♭/♯/m7 legibility on device before locking; diagrams stay SF.
- Signature element: the wave-string (from the logo) on the first-launch screen; on Tuner it is the sagging string; on Live/Song it is the real audio waveform under the chord beads. Mockups: https://claude.ai/code/artifact/776fc75b-7d00-4987-a13f-3d07d4954c22
- Storage: SQLite (sqlite-net-pcl), chord timeline as JSON per song; song metadata from YouTube (title/author/thumbnail) or ID3/filename.
- Light theme: Copper for text/icons, Gold for fills/glows only (contrast).
The A–B loop (design, 2026-09-09)
One tap on the chip takes the start: the chip lights in the accent, its label becomes B, it breathes, and on the conveyor a band grows from the start to the playhead — the loop being taken is never in doubt. A second tap takes the end (never closer than ChordTrack.MinLoopSeconds, a nearer tap is a change of mind and clears it); a third clears it.
A finished loop carries a grip at each end: a line down the band with a rounded handle on it, faded and sprung in, grown while held. Dragging one snaps to the nearest chord within 16 pt (with a click of haptic) and comes away again past that; the ends never cross and never come closer than the minimum; a grip held within 64 pt of an edge pulls the song along at up to 280 pt/s. The song pauses for the whole edit and does not start again — that is the player's to do.
None of this is drawn: the band is one BoxView moved and stretched by its transform (a canvas would have to be redrawn on every frame it grows) and the grips are moved the same way, so the per-frame rule holds. The beat-grid view only shows the loop — its squares take the accent over their own ground — and has no editor.
The chord editor (design, 2026-09-10)
The editor is a way of playing the song, not another page: the player, the transport, the play button and the chord steps stay where they are, and it works in both views. The header button becomes Готово and is the way out. The track is the editing surface, as in a DAW. A chord stays the badge at the moment it starts (a block over its whole length was tried and dropped, 2026-09-10): tap one to work on it, drag it to move where the chord begins — and with it the end of the chord before, since the song has no gaps — snapping to beats within 16 pt. The end of a chord is the next badge, and is moved there. The track rides along with the playhead until the reader touches it, and never again until asked. A pan, a tap, a chord taken hold of — any of them and the window is theirs; the song simply runs out of the frame, and nothing has to stop for a drag. Over the track a map of the whole song shows every chord, the playhead and the slice being looked at, and moves the window when tapped or dragged; beside it a Слідкувати chip appears whenever the track has been let go of, and takes it back to the playhead. The playhead never changes what is being worked on — that is the reader's choice and it keeps it while the song plays. Tapping beside the chords lets go of the choice. The panel sits over the song's slider, not under it, one row always (2026-09-15): step back, step on, the chord being worked on (tap to change it), delete, add. The two levels that matter while a song is taken apart — the song's and the metronome's — are in a small sheet of their own, opened from a sliders chip at the right end of the map's chip row (levels rows on the panel and a fold badge were tried and dropped). The track gives up the room: the chord diagrams give up their whole row in the conveyor view, and the beat view shows fewer squares. The YouTube player is folded away and cannot be unfolded, and leaving the editor brings it back the way it was before, and the "…" sheet's place goes to the switch between the two views. A–B has no place in the editor: its slot is the way back. Every change is written to the song the moment it is made, so the transport's A–B chip yields to an undo while the editor is on (thirty deep, cleared on the way in and out; user decision 2026-09-10). A loop is for playing a part over; editing, the one thing nothing else can do is take a change back. The map is the same strip over both views (user decision 2026-09-10): horizontal, above the squares in the beat view exactly as it sits above the track. There it is measured off the scroll — the rows on screen are the window — and dragging it scrolls the grid instead of moving the track. A timer that puts a lifted chord down belongs to the lift it was set in (a lift counter, in both views): the pooled view is often the same for the next drag, and the timer from the drag before put that chord down in the middle of it (2026-09-17). Anything that can be dragged wears a thumb. On the track the chord being worked on grows two grips; in the beat view its square carries a handle, and that handle is a view of its own — a canvas cannot be dragged, and on iOS a pan never reports where it began. Dragging it outlines the square it would land on, and the drop moves the chord's beginning there. A chord held near an edge scrolls the canvas under it in both views (2026-09-15): on the track the window comes along (the song plays on) by the loop ends' rule — 64 pt zone, up to 280 pt/s, patient for three seconds then up to four times as fast — and the chord and the hole it left keep their places; in the beat view the page scrolls the rows (56 pt zone, up to 320 pt/s, the same patience) and tells the grid how far, so the chord stays under the finger — the hold's points are in the grid's own coordinates and already carry the scroll, only the handle's pan deltas need it added, and the square never leaves the grid's edges (2026-09-15). Behind Pro, so the song shows exactly as stored — neither simplified nor transposed — and the picker offers the whole vocabulary: the root as a rail of round keys (never mistakable for a chord chip), then the chord, then the diagram of the choice, and nothing happens until Замінити / Додати is pressed. Replacing asks whether every chord of that name goes with it. Every change is saved as it is made.
Editor round (2026-09-14). The editor has two states and no third: a chord is chosen, or the panel says «Обери акорд на дошці». It opens on the chord under the playhead (user request 2026-09-14); after that nothing is chosen for the reader — not after an undo (it restores the choice as it was), not after a delete. The last choice is remembered by where the chord begins while the song stays open and comes back with the editor; leaving the song forgets it. In the beat view a square holds a chord only if it is the beat nearest that chord's start (Models/BeatMath.Nearest) — the one rule the grid draws by and the editor chooses by; an empty square lets go of the chord and is where a new one goes. A chord is the mark at the moment it starts and lasts until the next mark, and its mark can be dragged or stepped anywhere in the song: the chord is taken out of its place (the chord before plays on through its time) and its mark set down where it lands, where the chord lying there gives up the rest of its time; set down on a mark, or too close to one for a chord of its own, it takes that chord's place (Models/ChordEdits.Move, run on lists in checks; neighbours were walls until 2026-09-14). Other marks never move. On the track chords snap to the sixteenth (a beat cut in four, no buzz), the arrows step a sixteenth and a chord is added under the cursor to the sixteenth; in the beat view the arrows step a square. The chord being worked on wears dotted handles (bars read as "stretch me"). A chord is chosen by a tap, by a finger simply held on it, or by starting to drag it — in the beat view any chord can be taken from a hold, chosen or not (HoldDrag: a long press on iOS, a held pointer on Windows, so the scroll view keeps its scrolling) — and a small grow-and-settle pulse says it was chosen. The buzz comes with the hold, never with the drag; taking hold leaves the old place empty; the chord keeps its own colours on the finger (lifted colours were tried and dropped, 2026-09-14) and stays where it lands until the song's chords come back with the move. In the beat view the chosen square is outlined in the accent, never the text colour. (Played chords in a colour of their own were tried in the editor and dropped, 2026-09-14.) Tapping the chosen square again, a chord's or an empty one, takes the song there; on the track a tap beside the chords takes the song there and keeps the choice. Switching between the track and the beat view carries the place across: riding along stays riding along, a let-go view opens the other at the same moment in its middle, and the grid's chosen square is always the chosen chord's (an empty square only while it is the one chosen in the grid). The map draws the window exactly as the track sees it — never narrower than three playhead marks, widened about its own middle so a long song's sliver can still be found, and otherwise never pushed back inside; no edge lines or grips around it — its marks are centred on their moment and the chosen chord is a small square in the accent, in the beat view the window counts parts of rows, ChordTrack.LookAt centres what it is given, and on iOS a drag on the map starts where the finger came down (Platforms/iOS/TouchDownRecognizer, fed into PointerDrag.Started). The follow chip is filled with the accent, its icon alone, and never hides: a little dimmed and deaf while the view already rides along. The way back to the chosen chord is a plain chip with a map pin and the chord's name, so the two never read as one; their row keeps its height, so they never take room from the track, and the track or the grid under them starts the same gap below them as the map is above (the map row's own spacing). Scrolling the beat view by hand lets go of the playhead, as panning the track does. For a YouTube song the levels sheet's slider is the device volume, like the "…" sheet's. The chosen chord's name in the panel has the whole chip, with a small pencil over the top-right corner (the chip changes the chord; beside the name it cost the name its size, 2026-09-15), and is measured and shrunk to its chip, never cut. Chord previews in the shapes sheet and the picker play at the song's level, and PluckSynth.Strum brings them up to full scale through a soft saturation.
The tuner's headstock (design, 2026-09-11, second round)
The strings stand side by side, all parallel and straight up, spread across the whole width, and each one's peg sits at its top: a symmetric fir tree — the outer strings' pegs lowest, the middle ones' highest, the two halves mirror images. (Two clusters of pegs leaning in, with the strings fanning down to a nut, was tried first and read as two branches pulled apart.) The strings run on down under the tab bar in their pegs' colours: idle Separator (the idle peg's ring is Separator too), being tuned Accent, tuned Fill; the bass strings are thicker. The string being tuned shakes along its whole visible length at its own thickness, pinned at its peg and behind the tab bar, slowly (4.5–7 swings a second). A sounding string always visibly moves (at least 35 % of its widest swing, loudness adds the rest), dies away with the note, beats while out of tune and is steady once in; a string that gets tuned twangs, and the celebration twangs them all in a wave. The geometry comes from the measured peg size and the height from the page (TunerView.ArrangeNeck), never from the "start again" chip, so no peg moves under a finger. Per-frame exception: the strings are one canvas redrawn every frame while a string moves, and only then, like the tuner's own string. A bowed copy swung by a transform was tried first and rejected: a transform scales a line's thickness along with its swing, and the part of the string it did not cover stood still and looked wider (user report 2026-09-11).
Canvas text (iOS Core Text rule, 2026-09-07)
Text drawn on a GraphicsView goes through Core Text on iOS, which lays a line only into a box the whole line fits and draws nothing otherwise (DirectWrite on Windows draws and clips, so the Windows head never shows this). Never guess a font metric (a guessed 1.3× for Vollkorn lost the chord names a second time — its line is taller). Measure with the canvas: Theme.CanvasFonts.FitHeight(canvas, text, font, size, boxHeight) sizes text so its measured line takes ≤ 86 % of the box; Theme.CanvasFonts.Draw(canvas, text, font, size, box) draws it centred in a frame at least the measured line tall. Every DrawString goes through these. Canvas font names must be the real ones (CanvasFonts.Named("DisplayBold") → Vollkorn-Bold), not MAUI's aliases. Adaptive sizes ({t:Size}) shrink boxes on compact phones — the text must be sized from the box it gets, never the box from the text.
Text-measured layout refits (rule, 2026-09-07)
Any layout decided from measured text — hide a word when the title needs the room, a wave that takes what the brand name leaves, a quick row that wraps its last button — must be re-run after a language change and after a size-class change, not only on SizeChanged (those change the words and sizes without resizing the row). Register the fit with Theme.Refit.Watch(this, fit); it re-runs the fit once the bindings have applied. Canvas text (chord names, key chip, tab-bar labels) re-measures itself on every draw and needs nothing.
Icon glyphs (rule, 2026-09-11)
Glyphs in Controls/Icons.cs are optically centred in their own 24-unit box — the play triangle's centroid sits on the centre and its bounding box leans right. Never nudge an icon with a margin to centre it in a button: three play buttons did, and the sign sat visibly off to the right.
Markup extensions and the XAML service provider (rule, 2026-09-11)
{t:Theme} hands its service provider on to MAUI's AppThemeBindingExtension, which requires IProvideValueTarget and three more services and throws on a null provider. Never put [AcceptEmptyServiceProvider] on ThemeExtension, or on any extension that forwards the provider, to silence XamlC's XC0103: the compiled XAML then passes null, App's styles fail and the app dies before its first screen. TestFlight build 43 did exactly that while both heads built clean. A framework's internals are checked against the real package before code relies on them (a file-based dotnet run with #:package Microsoft.Maui.Controls.Xaml@<version> answers in a minute); the XC0103 warnings themselves are harmless.
6. Adaptive sizing (rule since 2026-08-27 — applies to every page, control and agent)
The app must look right from iPhone SE (375×667 pt) to iPad Pro 13" (1024×1366 pt). Points are a physical unit, so a hard-coded 60 pt button is 60 pt everywhere: too big on a compact phone, and on a tablet the whole chrome would either look lost or — if scaled linearly — turn into saucers. The design is therefore class-based, not proportional:
| Class | Shortest side | Devices | Chrome ({t:Size}) |
Hero ({t:Size …, Hero=True}) |
Layout |
|---|---|---|---|---|---|
| Compact | < 380 pt | iPhone SE, 12/13 mini | ×0.88, never below 44 pt | ×0.85 | as Regular |
| Regular | 380–599 pt | every other iPhone (reference: 393–430 pt) | ×1.0 | ×1.0 | as designed |
| Wide | ≥ 600 pt | iPad | ×1.0 — chrome does not grow | ×1.25 | content column capped at 672 pt ({t:ContentInset}); two-pane variants where they pay off (M8) |
The class is decided by the shortest side of the window (Theme/Metrics.cs), so rotating a phone does not change it; iPad Split View and a resized dev window do.
How to write sizes (mandatory):
- Never a magic number where something can be measured. A sheet is put off the bottom by its own measured height, not by a constant that a taller sheet outgrows (the "…" sheet showed a strip of itself the day the metronome's level row appeared, 2026-09-10); a control created in code takes
Metrics.Instance.Size(…)exactly as XAML takes{t:Size}. Fixed numbers are only right where they are a physical match to something else — the launch mark's 256×142 pt matching the static launch image, the welcome page's 342×90 grid mapping the lettering artwork — and each of those carries a comment saying so. - Chrome — buttons, chips, icons, thumbnails, sheet rows:
WidthRequest="{t:Size 60}",Size="{t:Size 24}". Touch targets:{t:Size 44, Min=44}— the sweep addsMin=44to anything ≥ 44 automatically; never let a tappable thing go under 44 pt. - Round buttons: pair
{t:Size 60}withStrokeShape="{t:Round 60}"so circles stay circles. - Hero content — the chord name, its diagram, the tuner note, the big points readout:
{t:Size 140, Hero=True}. Drawn controls (ChordDiagram,ChordTrack,TunerString,LevelMeter,PillTabBar) fit their container: give them star rows and they adapt by themselves — prefer this over any fixed size. - Ordinary text (12–29 pt) is not scaled by class: Dynamic Type owns it (
FontAutoScalingEnabledis on for every Label) and body text at 15–17 pt is right on every screen. - Page/section insets (20–24 pt) stay fixed; vertical space is distributed by
*rows, never by fixed heights. - Code-behind:
Theme.Metrics.Instance.Size(104)/Size(200, hero: true); never assign a bound size from code (it clears the binding). - Columns of content (settings, paywall, the tab views, the song page rows):
Margin="{t:ContentInset}"and keepHorizontalOptions="Fill";{t:ContentInset Plus=True}when the element carried its own 20 pt page inset. Ribbons stay full-width — the conveyor spans the whole screen on a tablet while the rows above and below it keep the column. Never centre a column withHorizontalOptions="Center"— it takes its natural width and overflows a narrow phone (seen on the 13 mini profile). - Text drawn on a canvas that must fit a slot (tab-bar labels, chord names, fret numbers): measure with
GetStringSizeand shrink the font to fit, with a floor — the wayPillTabBar.LabelFontandChordDiagramdo. - Do not use
OnIdiomfor sizes (SE and Pro Max are both "Phone"),Scaletransforms (blurry, wrong hit areas) or per-pageSizeChangedmaths (unmaintainable across 14 pages) — the tokens above are the only mechanism.
Orientation (2026-08-27): iPhone is portrait only (Info.plist UISupportedInterfaceOrientations) — every screen is laid out for a tall viewport. iPad allows all orientations; landscape variants of the song page (player + chords left, conveyor right) are M8 work. The Landscape · … launch profiles exist for the iPad case and for checking nothing breaks if a phone layout is squeezed.
Test every change in at least three profiles (Visual Studio ▸ launch profile, or STRUNIKA_WINDOW=375x667): iPhone SE, iPhone 16, iPad 11". Profiles exist for SE, 13 mini, 16, 16 Pro Max, iPad mini, iPad 11", iPad Pro 13". The welcome and launch pages are deliberately excluded from scaling (pixel-aligned lettering; both fit the smallest screen).
7. Per-frame rendering rule (learned the hard way, 2026-08-27)
Nothing that runs every frame may draw a canvas, update a native control, or create a native object. The song page stuttered ~100 ms once a second on the Windows head; the GC events showed gen2 InducedNotForced collections on the UI thread — the WinUI XAML runtime induces full collections when native objects (Win2D text layouts and brushes, native control updates, MAUI transform objects) churn, and with a ~20 MB managed heap the threshold is tiny. iOS has no such mechanism, but the same code also burns battery there. The rule and the building blocks:
- Move, do not redraw.
ChordTrackrenders its ribbon (bars, beat ruler, chord pills) into canvases three screens wide once every few seconds and slides them with a transform every frame. The played/coming bar colours are two full renderings, each in a container clipped to its side of the playhead. Each rendering is double-buffered: the next window is drawn into a spare canvas and shown only after itsDrawhas run (a canvas invalidated and shown in the same frame flashes stale content at the new offset). A far jump (scrub across the song) renders straight into the visible buffer instead. NativeTransform.TranslateX/ScaleXfor anything moved per frame: on Windows it writes into oneCompositeTransformcreated once (MAUI's ownTranslationXallocates a transform object on every change); elsewhere it is the MAUI property.- No native slider on a per-frame path.
SeekBar(track, fill, thumb — three views moved by transforms; two-wayValue,Duration; drag/tap events) replacesSliderfor the position and the level controls on the song page. Updating a WinUISlider.Valueten times a second alone caused most of the induced collections. - Drags through
PointerDrag.Attach: on Windows it captures the pointer natively (a MAUI pan released outside the element ends as "cancelled" with the start offset, snapping the thumb back); elsewhere it is the pan gesture. A press without movement is a tap. - Drawables tolerate teardown (
Handler == null→ return; NRE/E_INVALIDARG/COM caught) and the frame ticker stops onUnloadedandWindow.Destroying— never testHandlerin the first frames, on Windows they run before it exists. - Subscriptions from a page or its view-model to long-lived objects (
Window.Destroying,IProGate.Changed,AppSettings.Changed) must be removed on dispose, or every opened page stays in memory and every collection gets longer. - The player's time is the median of the last five readings carried to now (
MedianClock), never the latest reading alone: a YouTube clock wanders by ±70 ms between probes on some songs, and chasing each reading had the conveyor slowing and hurrying in mid-song (2026-09-15). A reading the player repeats is taken as the reading plus its age (the time since it first appeared): a clock that moves in quarter-second steps for a song's first seconds then reads as smooth, where easing towards the raw steps had the conveyor slowing and hurrying every half second (2026-09-15). A probe reading the same position as the one before is buffering only when it happens twice running: a clock that moves in quarter-second steps repeats itself now and then, and snapping back to it each time kept the conveyor a step behind the sound for a whole song (2026-09-15). The shared engine asks the session for a 20 ms IO cycle before it starts (23 came back as 85 on the speaker; the hardware rounds to its own sizes): on AirPods the route ran at 10 ms while a Bluetooth link encodes in ~21 ms frames, and the metronome ticked that way for twenty minutes beside WebKit's audio before the sound turned to grating (log of 2026-09-15; the best lead, not proof). With the metronome off, and when the song page is left, the click player rests the shared engine unless a file song plays through it (IClickPlayer.Rest): an engine running on, rendering silence at a 10 ms buffer over a Bluetooth route, is the one thing of ours in the audio path during a YouTube song, and the sound on AirPods turned to grating during one (log of 2026-09-15; not proven to be the cause). A tick due within the click player's latency (route latency plus an IO block) is already in the stream and is never placed again on a resync — doing so played a beat twice, often on AirPods (163 ms) and with a large block on the speaker (2026-09-15). For a YouTube song the metronome's ticks are placed by the truest clock — the prediction plus what the last probe found still to be made good — never by the smoothed clock the conveyor moves to, and a probe that moves that clock by more than 15 ms re-places the ticks in the air; a probe whose round trip is far longer than usual is let by, its reading being anywhere inside the trip (2026-09-15). The song's predicted position pays off a probe's correction as a change of pace (half to one-and-a-half speed), never as a step back: on a still track the playhead itself moves, and a step back on every probe was a visible twitch (2026-09-14). The song page logs a 5 s frame summary (frames 5 s: … hitches … gc2 … managed …) and any frame over 50 ms in debug builds — read that before guessing.