iOS app shell: nav bar, tab bar, push, sheet and split view
A UITabBarController + UINavigationController shell built from constraints.
<!--
A UITabBarController + UINavigationController shell. Every bar, guide, scroll view,
pushed screen and sheet is placed by data-⚓️-* attributes. Positioned siblings instead
of nested subviews: an anchor has to be a descendant of the target's containing block,
so a bar's buttons sit next to the bar and constrain to it by name.
The attributes below are the compact (iPhone) constraint set; script.js activates the
regular (iPad split view) set on a size-class change, like NSLayoutConstraint.activate().
-->
<div class="window" data-⚓️-name="--window" data-size="compact" data-sim>
<div class="app" data-⚓️-root>
<!-- ===== layout guides (UILayoutGuide) — invisible boxes other views constrain to ===== -->
<div class="guide g-screen" data-key="screen" data-⚓️-name="--screen" data-⚓️-pin-all></div>
<div class="guide g-safe" data-key="safe" data-⚓️-name="--safe" data-⚓️-pin-all></div>
<div class="guide g-primary" data-key="primary" data-⚓️-name="--primary" data-⚓️-pin-all></div>
<div class="guide g-secondary" data-key="secondary" data-⚓️-name="--secondary" data-⚓️-pin-all></div>
<!-- ===== navigation bar (anchor) + tab bar (anchor) ===== -->
<div class="nav" data-key="nav" data-⚓️-name="--nav"
data-⚓️-to="--safe" data-⚓️-align-top data-⚓️-stretch-x="--primary"></div>
<nav class="tabbar" data-key="tabbar" data-⚓️-name="--tabbar" aria-label="Tabs"
data-⚓️-to="--safe" data-⚓️-align-bottom data-⚓️-stretch-x="--screen">
<button class="tab" data-tab="listen" aria-pressed="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path class="solid" d="M10 8.2v7.6l6-3.8z"/></svg>
<span>Listen Now</span>
</button>
<button class="tab" data-tab="library" aria-pressed="true">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="7" width="18" height="14" rx="3"/><path d="M6 4h12M8.5 1.5h7"/></svg>
<span>Library</span>
</button>
<button class="tab" data-tab="search" aria-pressed="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/></svg>
<span>Search</span>
</button>
</nav>
<!-- ===== the scroll view: top = navBar.bottom, bottom = tabBar.top ===== -->
<main class="content" data-key="content" data-⚓️-name="--content"
data-⚓️-below="--nav" data-⚓️-above="--tabbar" data-⚓️-stretch-x="--primary">
<section class="panel" data-panel="library">
<h1 class="large-title">Library</h1>
<h2 class="group-head">Auto Layout</h2>
<ul class="group">
<li><label class="cell switch-cell"><span>Simulate safe area</span><input type="checkbox" role="switch" id="opt-sim" checked></label></li>
<li><label class="cell switch-cell"><span>Extend under bars</span><input type="checkbox" role="switch" id="opt-extend"></label></li>
<li class="wide-only"><label class="cell switch-cell"><span>Compact width (iPhone)</span><input type="checkbox" role="switch" id="opt-compact"></label></li>
</ul>
<p class="group-foot">Extend under bars swaps the scroll view's top/bottom constraints from the bars to the superview, so content scrolls behind the blur.</p>
<h2 class="group-head">Recently Added</h2>
<ul class="group albums">
<li><button class="cell album-row" data-hue="18" style="--hue: 18" data-title="Currents" data-artist="Tame Impala" data-year="2015" data-tracks="Let It Happen|Nangs|The Moment|Yes I'm Changing|Eventually|Gossip">
<span class="art" aria-hidden="true"></span><span class="lines"><b>Currents</b><small>Tame Impala</small></span><i class="chev" aria-hidden="true"></i></button></li>
<li><button class="cell album-row" data-hue="200" style="--hue: 200" data-title="In Rainbows" data-artist="Radiohead" data-year="2007" data-tracks="15 Step|Bodysnatchers|Nude|Weird Fishes/Arpeggi|All I Need|Reckoner">
<span class="art" aria-hidden="true"></span><span class="lines"><b>In Rainbows</b><small>Radiohead</small></span><i class="chev" aria-hidden="true"></i></button></li>
<li><button class="cell album-row" data-hue="300" style="--hue: 300" data-title="Random Access Memories" data-artist="Daft Punk" data-year="2013" data-tracks="Give Life Back to Music|The Game of Love|Giorgio by Moroder|Within|Instant Crush|Get Lucky">
<span class="art" aria-hidden="true"></span><span class="lines"><b>Random Access Memories</b><small>Daft Punk</small></span><i class="chev" aria-hidden="true"></i></button></li>
<li><button class="cell album-row" data-hue="140" style="--hue: 140" data-title="Blonde" data-artist="Frank Ocean" data-year="2016" data-tracks="Nikes|Ivy|Pink + White|Solo|Self Control|Nights">
<span class="art" aria-hidden="true"></span><span class="lines"><b>Blonde</b><small>Frank Ocean</small></span><i class="chev" aria-hidden="true"></i></button></li>
<li><button class="cell album-row" data-hue="260" style="--hue: 260" data-title="Vespertine" data-artist="Björk" data-year="2001" data-tracks="Hidden Place|Cocoon|It's Not Up to You|Undo|Pagan Poetry|Aurora">
<span class="art" aria-hidden="true"></span><span class="lines"><b>Vespertine</b><small>Björk</small></span><i class="chev" aria-hidden="true"></i></button></li>
<li><button class="cell album-row" data-hue="60" style="--hue: 60" data-title="Moon Safari" data-artist="Air" data-year="1998" data-tracks="La Femme d'Argent|Sexy Boy|All I Need|Kelly Watch the Stars|Talisman|Remember">
<span class="art" aria-hidden="true"></span><span class="lines"><b>Moon Safari</b><small>Air</small></span><i class="chev" aria-hidden="true"></i></button></li>
</ul>
<p class="group-foot">Tap an album to push it. On a regular-width window it opens in the split view's secondary column instead.</p>
</section>
<section class="panel" data-panel="listen" hidden>
<h1 class="large-title">Listen Now</h1>
<h2 class="group-head">Top Picks</h2>
<ul class="group picks">
<li class="cell pick" style="--hue: 330"><b>Late Night Anchors</b><small>Made for you</small></li>
<li class="cell pick" style="--hue: 190"><b>Constraint Chill</b><small>Updated Friday</small></li>
<li class="cell pick" style="--hue: 40"><b>Safe Area Classics</b><small>Hits from the notch era</small></li>
</ul>
</section>
<section class="panel" data-panel="search" hidden>
<h1 class="large-title">Search</h1>
<label class="search"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/></svg><input type="search" placeholder="Artists, Songs, Lyrics and More" aria-label="Search"></label>
<h2 class="group-head">Recently Searched</h2>
<ul class="group">
<li class="cell">position-anchor</li>
<li class="cell">anchor-size()</li>
<li class="cell">@position-try</li>
</ul>
</section>
</main>
<!-- ===== bar backgrounds: extend past the safe area to the superview edges ===== -->
<div class="bar-bg bar-top" data-key="barTop" data-⚓️-pin-top data-⚓️-stretch-x="--primary" data-⚓️-align-bottom="--nav" aria-hidden="true"></div>
<div class="bar-bg bar-bottom" data-key="barBottom" data-⚓️-align-top="--tabbar" data-⚓️-pin-bottom data-⚓️-stretch-x="--screen" aria-hidden="true"></div>
<!-- ===== navigation bar items ===== -->
<p class="nav-title" data-key="navTitle" data-⚓️-to="--nav" data-⚓️-center-all>Library</p>
<button class="bar-btn nav-action" data-key="navAction" aria-label="New playlist"
data-⚓️-to="--nav" data-⚓️-align-trailing data-⚓️-align-y="center">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
</button>
<!-- ===== pushed view controller ===== -->
<section class="detail" data-key="detail" data-⚓️-name="--detail" aria-label="Album" inert
data-⚓️-to="--screen" data-⚓️-side="after" data-⚓️-sync-width data-⚓️-pin-top data-⚓️-above="--tabbar"
data-⚓️-transition="morph">
<div class="guide d-safe" data-key="dSafe" data-⚓️-name="--d-safe" data-⚓️-pin-all></div>
<div class="d-nav" data-key="dNav" data-⚓️-name="--d-nav" data-⚓️-to="--d-safe" data-⚓️-align-top data-⚓️-pin-x></div>
<div class="d-content" data-key="dContent" data-⚓️-below="--d-nav" data-⚓️-pin-x data-⚓️-pin-bottom>
<div class="album-head">
<span class="hero-art" aria-hidden="true"></span>
<h2 class="album-title">Currents</h2>
<p class="album-artist">Tame Impala</p>
<p class="album-meta">Psychedelic · 2015 · Lossless</p>
<div class="actions">
<button class="pill"><svg viewBox="0 0 24 24" aria-hidden="true"><path class="solid" d="M7 4.5v15l12-7.5z"/></svg>Play</button>
<button class="pill"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7h4l10 10h4M3 17h4l3-3m4-4 3-3h4m-3-3 3 3-3 3m0 4 3 3-3 3"/></svg>Shuffle</button>
</div>
</div>
<ol class="tracks"></ol>
<p class="group-foot tracks-foot"></p>
</div>
<div class="bar-bg d-bar" data-key="dBar" data-⚓️-pin-top data-⚓️-pin-x data-⚓️-align-bottom="--d-nav" aria-hidden="true"></div>
<button class="bar-btn d-back" data-key="dBack" data-⚓️-to="--d-nav" data-⚓️-align-leading data-⚓️-align-y="center">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 4 7 12l8 8"/></svg><span>Library</span>
</button>
<p class="nav-title d-title" data-key="dTitle" data-⚓️-to="--d-nav" data-⚓️-center-all>Currents</p>
<button class="bar-btn d-more" data-key="dMore" aria-label="Add to playlist"
data-⚓️-to="--d-nav" data-⚓️-align-trailing data-⚓️-align-y="center">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M7.5 12h.01M12 12h.01M16.5 12h.01" stroke-width="2.6"/></svg>
</button>
</section>
<!-- ===== inspector button (a floating accessory view) ===== -->
<button class="fab" data-key="fab" data-⚓️-name="--fab" aria-expanded="false" aria-controls="inspector"
data-⚓️-to="--tabbar" data-⚓️-above data-⚓️-align-trailing="--safe">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M3 9h18M9 9v12"/></svg>
<span>Constraints</span>
</button>
<!-- ===== simulated hardware (only when "Simulate safe area" is on) ===== -->
<div class="sim status-bar" data-key="statusBar" data-⚓️-pin-top data-⚓️-pin-x data-⚓️-above="--safe" aria-hidden="true">
<span class="time">9:41</span><span class="glyphs"><i></i><i></i><i></i></span>
</div>
<div class="sim island" data-key="island" data-⚓️-to="--screen" data-⚓️-align-top data-⚓️-align-x="center" aria-hidden="true"></div>
<div class="sim home-indicator" data-key="home" data-⚓️-to="--screen" data-⚓️-align-bottom data-⚓️-align-x="center" aria-hidden="true"></div>
</div>
<!-- ===== modal sheet (UISheetPresentationController) ===== -->
<div class="sheet" id="sheet" popover data-key="sheet" data-⚓️-name="--sheet" data-detent="medium" role="dialog" aria-labelledby="sheet-title"
data-⚓️-to="--window" data-⚓️-align-bottom data-⚓️-stretch-x data-⚓️-sync-height>
<div class="guide s-safe" data-key="sSafe" data-⚓️-name="--s-safe" data-⚓️-pin-all></div>
<button class="grabber" data-key="grabber" aria-label="Resize sheet" data-⚓️-pin-top data-⚓️-pin-x data-⚓️-align-self="center"></button>
<header class="s-head">
<div><h2 id="sheet-title">Add to a Playlist</h2><p class="s-hint">Drag or tap the grabber for the large detent</p></div>
<button class="s-close" popovertarget="sheet" popovertargetaction="hide" aria-label="Close">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 7 10 10M17 7 7 17"/></svg>
</button>
</header>
<ul class="group s-list">
<li><label class="cell check-cell"><span class="art" style="--hue: 330" aria-hidden="true"></span><span>Late Night Anchors</span><input type="checkbox"></label></li>
<li><label class="cell check-cell"><span class="art" style="--hue: 190" aria-hidden="true"></span><span>Constraint Chill</span><input type="checkbox" checked></label></li>
<li><label class="cell check-cell"><span class="art" style="--hue: 40" aria-hidden="true"></span><span>Safe Area Classics</span><input type="checkbox"></label></li>
<li><label class="cell check-cell"><span class="art" style="--hue: 100" aria-hidden="true"></span><span>Road Trip</span><input type="checkbox"></label></li>
</ul>
<button class="s-done" data-key="sDone" popovertarget="sheet" popovertargetaction="hide"
data-⚓️-to="--s-safe" data-⚓️-align-bottom data-⚓️-stretch-x>Done</button>
</div>
<!-- ===== constraints inspector (doubles as X-ray) ===== -->
<aside class="inspector" id="inspector" popover="manual" aria-label="Constraints inspector"
data-⚓️-to="--fab" data-⚓️-above data-⚓️-align-trailing>
<header><b>Constraints</b><span class="trait"></span></header>
<div class="i-list"></div>
</aside>
<div class="hl-box" popover="manual" data-⚓️-to="--screen" data-⚓️-fill="--screen" data-⚓️-transition="glide" aria-hidden="true"></div>
<div class="hl-label" popover="manual" data-⚓️-to="--screen" data-⚓️-side="below" data-⚓️-align-leading data-⚓️-try="flip-y" aria-hidden="true"></div>
</div>/* Demo styling only: colours, type, materials and intrinsic sizes (a bar's height is its
intrinsicContentSize). Every position comes from the data-⚓️-* attributes in the HTML
and the constraint sets script.js activates per size class. */
/* ---------- iOS system colours ---------- */
:root {
color-scheme: light dark;
--tint: #fa243c;
--bg: #f2f2f7; /* systemGroupedBackground */
--cell: #fff; /* secondarySystemGroupedBackground */
--plain: #fff; /* systemBackground */
--fill: rgb(118 118 128 / 0.12);
--label: #000;
--label-2: rgb(60 60 67 / 0.6);
--label-3: rgb(60 60 67 / 0.3);
--sep: rgb(60 60 67 / 0.29);
--bar: rgb(249 249 249 / 0.78);
--hl: #0a84ff;
--ease: cubic-bezier(0.32, 0.72, 0, 1); /* UIKit's navigation curve, near enough */
font-family: system-ui, -apple-system, "SF Pro Text", "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
@media (prefers-color-scheme: dark) {
:root {
--tint: #ff375f;
--bg: #000;
--cell: #1c1c1e;
--plain: #000;
--fill: rgb(118 118 128 / 0.24);
--label: #fff;
--label-2: rgb(235 235 245 / 0.6);
--label-3: rgb(235 235 245 / 0.3);
--sep: rgb(84 84 88 / 0.65);
--bar: rgb(22 22 22 / 0.78);
--sheet-bg: #1c1c1e; /* elevated grouped background */
--sheet-cell: #2c2c2e;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--label); }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
svg { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
svg .solid { fill: currentColor; stroke: none; }
:focus-visible { outline: 2px solid var(--hl); outline-offset: 2px; }
/* ---------- the window + safe area ----------
env() when the device reports one, otherwise the simulated hardware values. */
.window {
--sim-top: 0px;
--sim-bottom: 0px;
--safe-top: max(env(safe-area-inset-top), var(--sim-top));
--safe-bottom: max(env(safe-area-inset-bottom), var(--sim-bottom));
--safe-left: env(safe-area-inset-left);
--safe-right: env(safe-area-inset-right);
height: 100dvh;
overflow: clip;
background: #000;
}
.window[data-sim][data-size="compact"] { --sim-top: 54px; --sim-bottom: 34px; }
.window[data-sim][data-size="regular"] { --sim-top: 24px; --sim-bottom: 20px; }
.app {
height: 100%;
overflow: clip;
background: var(--bg);
timeline-scope: --list, --large-title, --album-title;
transition: scale 0.45s var(--ease), border-radius 0.45s var(--ease);
}
/* Desktop: the compact trait is shown as a phone; otherwise the window is the whole tab. */
@media (min-width: 768px) {
body:has(.window[data-size="compact"]) {
display: grid;
place-items: center;
min-height: 100dvh;
background: radial-gradient(circle at 50% 30%, color-mix(in oklab, var(--tint) 18%, var(--bg)), var(--bg) 70%);
}
.window[data-size="compact"] {
width: 393px;
height: min(852px, 100dvh - 48px);
border-radius: 54px;
box-shadow: 0 0 0 11px #1b1b1d, 0 0 0 12px #3a3a3c, 0 40px 80px -20px rgb(0 0 0 / 0.5);
}
.window[data-size="compact"] .app { border-radius: 54px; }
}
.wide-only { display: none; }
@media (min-width: 768px) { .wide-only { display: block; } }
/* ---------- layout guides ---------- */
.guide { pointer-events: none; }
.g-safe { margin: var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left); }
.window[data-size="regular"] .g-primary {
--⚓️-mult-x: 0.34; /* primary.width = 0.34 × superview.width (optional priority) */
min-width: 300px; /* ≥ 300 and ≤ 380 are required priority, so they win */
max-width: 380px;
border-inline-end: 0.5px solid var(--sep);
background: var(--bg);
}
.window[data-size="regular"] .g-secondary { background: var(--plain); }
/* ---------- bars ---------- */
.nav, .d-nav { height: 44px; }
.bar-bg {
pointer-events: none;
background: var(--bar);
backdrop-filter: saturate(180%) blur(20px);
}
.bar-top { z-index: 2; box-shadow: 0 0.5px 0 var(--sep); }
.bar-bottom { z-index: 2; box-shadow: 0 -0.5px 0 var(--sep); }
.window[data-size="regular"] .bar-bottom { display: none; }
/* Large-title behaviour: the bar is clear at the scroll edge and turns to material as soon
as content scrolls; the inline title fades in as the large title passes under it. */
@supports (animation-timeline: scroll()) {
.content { scroll-timeline: --list y; }
.large-title { view-timeline: --large-title y; }
.album-title { view-timeline: --album-title y; }
.bar-top { animation: to-material linear both; animation-timeline: --list; animation-range: 0px 12px; }
.app > .nav-title { animation: fade-in linear both; animation-timeline: --large-title; animation-range: exit 30% exit 90%; }
.d-title { animation: fade-in linear both; animation-timeline: --album-title; animation-range: exit 0% exit 100%; }
.d-bar { animation: to-material linear both; animation-timeline: --album-title; animation-range: exit -120% exit -100%; }
}
@keyframes to-material { from { background: transparent; backdrop-filter: none; box-shadow: none; } }
@keyframes fade-in { from { opacity: 0; } }
.nav-title {
z-index: 3;
margin: 0;
max-width: calc(100% - 200px); /* leading ≥ back.trailing, trailing ≤ button.leading */
pointer-events: none;
font-weight: 600;
font-size: 17px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar-btn {
z-index: 3;
display: flex;
align-items: center;
gap: 2px;
min-width: 44px;
min-height: 44px;
justify-content: center;
color: var(--tint);
font-size: 17px;
}
.bar-btn svg { font-size: 22px; }
.nav-action { margin-inline-end: 8px; } /* layout margin 16 = 8 + the 44pt hit target's own inset */
.d-back { margin-inline-start: 4px; justify-content: start; padding-inline-end: 8px; }
.d-back svg { font-size: 24px; stroke-width: 2.4; }
.d-more { margin-inline-end: 8px; }
/* Tab bar: compact = full-width bar, 49pt tall, items fillEqually. */
.tabbar {
z-index: 6;
height: 49px;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
}
.tab {
display: grid;
justify-items: center;
align-content: start;
gap: 2px;
padding-top: 7px;
color: rgb(153 153 153);
font-size: 10px;
font-weight: 500;
}
.tab svg { font-size: 25px; stroke-width: 1.8; }
.tab[aria-pressed="true"] { color: var(--tint); }
.tab[aria-pressed="true"] svg { fill: color-mix(in srgb, currentColor 18%, transparent); }
/* Regular: the iPadOS floating tab bar, centred over the secondary column. */
.window[data-size="regular"] .tabbar {
height: 44px;
margin-top: 0;
padding: 4px;
gap: 2px;
border-radius: 22px;
background: var(--bar);
backdrop-filter: saturate(180%) blur(20px);
box-shadow: 0 0 0 0.5px var(--sep), 0 6px 24px rgb(0 0 0 / 0.12);
}
.window[data-size="regular"] .tab {
display: flex;
align-items: center;
padding: 0 16px;
border-radius: 18px;
color: var(--label);
font-size: 15px;
font-weight: 600;
}
.window[data-size="regular"] .tab svg { display: none; }
.window[data-size="regular"] .tab[aria-pressed="true"] { background: var(--fill); color: var(--tint); }
/* ---------- scroll view ---------- */
.content {
z-index: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding-inline: max(16px, var(--safe-left)) max(16px, var(--safe-right));
padding-block-end: 24px;
scroll-padding-top: 0;
transition: translate 0.5s var(--ease), filter 0.5s var(--ease);
}
/* Extended under the bars: the scroll view now runs edge to edge, so its content insets
(adjustedContentInset) carry the bar heights instead of the constraints. */
.app[data-extend] .content {
padding-top: calc(var(--safe-top) + 44px);
padding-bottom: calc(var(--safe-bottom) + 49px + 24px);
scroll-padding-top: calc(var(--safe-top) + 44px);
}
.app[data-extend] .large-title { view-timeline-inset: calc(var(--safe-top) + 44px) 0px; }
.window[data-size="regular"] .app[data-extend] .content { padding-bottom: calc(var(--safe-bottom) + 24px); }
.large-title {
margin: 2px 0 8px;
padding-inline: 4px;
font-size: 34px;
line-height: 41px;
font-weight: 700;
letter-spacing: 0.01em;
}
.group-head {
margin: 22px 0 7px;
padding-inline: 20px;
font-size: 13px;
font-weight: 400;
text-transform: uppercase;
color: var(--label-2);
}
.group-foot { margin: 7px 0 0; padding-inline: 20px; font-size: 13px; line-height: 1.35; color: var(--label-2); }
.group {
margin: 0;
padding: 0;
list-style: none;
border-radius: 10px;
background: var(--cell);
overflow: clip;
}
.cell {
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: 44px;
padding: 6px 16px;
font-size: 17px;
text-align: start;
}
/* hairline separator, inset to the text's leading edge like UITableView */
.group > li + li > .cell::before,
.group > li.cell + li.cell::before {
content: "";
position: absolute;
inset: 0 0 auto 16px;
border-top: 0.5px solid var(--sep);
}
.albums > li + li > .cell::before { inset-inline-start: 76px; }
button.cell:active, button.cell[aria-current="true"] { background: var(--fill); }
.window[data-size="regular"] button.cell[aria-current="true"] { background: var(--tint); color: #fff; }
.window[data-size="regular"] button.cell[aria-current="true"] small { color: rgb(255 255 255 / 0.8); }
.switch-cell { justify-content: space-between; cursor: pointer; }
input[role="switch"] {
appearance: none;
flex: none;
width: 51px;
height: 31px;
margin: 0;
border-radius: 99px;
background: var(--fill);
position: relative;
cursor: pointer;
transition: background 0.2s;
}
input[role="switch"]::after {
content: "";
position: absolute;
inset: 2px auto 2px 2px;
aspect-ratio: 1;
border-radius: 50%;
background: #fff;
box-shadow: 0 3px 8px rgb(0 0 0 / 0.15), 0 1px 1px rgb(0 0 0 / 0.16);
transition: translate 0.2s var(--ease);
}
input[role="switch"]:checked { background: #34c759; }
input[role="switch"]:checked::after { translate: 20px; }
.art {
flex: none;
width: 48px;
aspect-ratio: 1;
border-radius: 6px;
background:
radial-gradient(circle at 30% 25%, oklch(90% 0.12 calc(var(--hue) + 40)), transparent 55%),
linear-gradient(135deg, oklch(65% 0.2 var(--hue)), oklch(35% 0.15 calc(var(--hue) + 60)));
box-shadow: inset 0 0 0 0.5px rgb(0 0 0 / 0.1);
}
.album-row { padding-block: 8px; }
.album-row .art { width: 48px; }
.lines { display: grid; flex: 1; min-width: 0; }
.lines b { font-weight: 400; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lines small, .pick small { font-size: 15px; color: var(--label-2); }
.chev { width: 8px; height: 8px; border: solid var(--label-3); border-width: 2px 2px 0 0; rotate: 45deg; margin-inline-end: 4px; }
.window[data-size="regular"] .chev { display: none; }
.picks { background: none; display: grid; gap: 12px; }
.pick {
display: grid;
align-content: end;
min-height: 160px;
padding: 16px;
border-radius: 12px;
color: #fff;
background: linear-gradient(160deg, oklch(62% 0.2 var(--hue)), oklch(30% 0.12 calc(var(--hue) + 50)));
}
.pick b { font-size: 22px; }
.pick small { color: rgb(255 255 255 / 0.75); }
.search {
display: flex;
align-items: center;
gap: 6px;
height: 36px;
padding-inline: 8px;
border-radius: 10px;
background: var(--fill);
color: var(--label-2);
}
.search input { flex: 1; border: 0; background: none; font: inherit; font-size: 17px; color: var(--label); outline: none; }
/* ---------- pushed detail ---------- */
.detail {
z-index: 5;
background: var(--plain);
box-shadow: 0 0 0 transparent;
}
.window[data-size="compact"] .detail { box-shadow: -10px 0 30px rgb(0 0 0 / 0.14); }
.window[data-size="compact"] .detail[inert] { box-shadow: none; }
.d-safe { margin: var(--safe-top) var(--safe-right) 0 var(--safe-left); }
.window[data-size="regular"] .d-safe { margin: var(--safe-top) 0 var(--safe-bottom) 0; }
.d-bar { z-index: 2; box-shadow: 0 0.5px 0 var(--sep); }
.d-content { overflow-y: auto; overscroll-behavior: contain; padding: 8px 20px 32px; }
.window[data-size="regular"] .d-back,
.window[data-size="regular"] .d-title { display: none; }
.window[data-size="regular"] .d-content { padding-top: 24px; }
.album-head { display: grid; justify-items: center; text-align: center; }
.hero-art {
--hue: var(--album-hue, 18);
width: min(62%, 280px);
aspect-ratio: 1;
border-radius: 10px;
background:
radial-gradient(circle at 30% 25%, oklch(90% 0.12 calc(var(--hue) + 40)), transparent 55%),
linear-gradient(135deg, oklch(65% 0.2 var(--hue)), oklch(35% 0.15 calc(var(--hue) + 60)));
box-shadow: 0 14px 30px -8px oklch(40% 0.15 var(--hue) / 0.55);
}
.album-title { margin: 18px 0 0; font-size: 20px; font-weight: 600; }
.album-artist { margin: 2px 0 0; font-size: 20px; color: var(--tint); }
.album-meta { margin: 4px 0 0; font-size: 12px; font-weight: 600; text-transform: uppercase; color: var(--label-2); }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; width: 100%; max-width: 420px; margin-top: 16px; }
.pill {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 48px;
border-radius: 12px;
background: var(--fill);
color: var(--tint);
font-size: 17px;
font-weight: 600;
}
.tracks { margin: 20px 0 0; padding: 0; list-style: none; counter-reset: t; }
.tracks li {
counter-increment: t;
display: flex;
gap: 16px;
align-items: center;
min-height: 44px;
font-size: 17px;
box-shadow: 0 -0.5px 0 var(--sep);
}
.tracks li::before { content: counter(t); width: 16px; text-align: end; color: var(--label-2); }
.tracks-foot { padding: 12px 0 0; }
/* Push: the root screen slides a third of the way out and dims, like UIKit's parallax. */
.window[data-size="compact"] .app[data-pushed] :is(.content, .nav-title, .nav-action, .bar-top) {
translate: -30% 0;
}
.window[data-size="compact"] .app[data-pushed] .content { filter: brightness(0.92); }
.app > :is(.nav-title, .nav-action, .bar-top) { transition: translate 0.5s var(--ease); }
/* ---------- floating accessory + simulated hardware ---------- */
.fab {
z-index: 7;
display: flex;
align-items: center;
gap: 6px;
margin: 0 16px 12px 0;
padding: 8px 14px;
border-radius: 99px;
background: var(--bar);
backdrop-filter: saturate(180%) blur(20px);
box-shadow: 0 0 0 0.5px var(--sep), 0 6px 20px rgb(0 0 0 / 0.15);
font-size: 13px;
font-weight: 600;
color: var(--hl);
}
.fab svg { font-size: 16px; }
.fab[aria-expanded="true"] { background: var(--hl); color: #fff; }
.window[data-size="regular"] .fab { margin: 0 20px 20px 0; }
.sim { display: none; z-index: 9; pointer-events: none; }
.window[data-sim] .sim { display: flex; }
.window[data-size="regular"] .island { display: none; }
.status-bar {
align-items: center;
justify-content: space-between;
padding: 0 34px 0 44px;
font-size: 17px;
font-weight: 600;
}
.window[data-size="regular"] .status-bar { padding: 0 22px; font-size: 13px; }
.glyphs { display: flex; gap: 5px; align-items: center; }
.glyphs i { width: 17px; height: 11px; border-radius: 3px; background: currentColor; }
.glyphs i:first-child { clip-path: polygon(0 100%, 100% 0, 100% 100%); border-radius: 0; }
.glyphs i:last-child { width: 25px; border: 1px solid currentColor; background: linear-gradient(90deg, currentColor 70%, transparent 0) content-box; padding: 1.5px; opacity: 0.9; }
.island { width: 125px; height: 37px; margin-top: 11px; border-radius: 20px; background: #000; }
.home-indicator { width: 134px; height: 5px; margin-bottom: 8px; border-radius: 3px; background: var(--label); }
.window[data-size="regular"] .home-indicator { width: 300px; margin-bottom: 7px; }
/* card-stack: the presenting view shrinks back behind a large-detent sheet */
.window:has(.sheet:popover-open[data-detent="large"])[data-size="compact"] .app {
scale: 0.93;
border-radius: 12px;
}
/* ---------- sheet ---------- */
.sheet {
--⚓️-mult-y: 0.5; /* .medium detent: height = 0.5 × window.height */
--⚓️-offset-y: 0px;
padding: 0;
border: 0;
border-radius: 12px 12px 0 0;
background: var(--sheet-bg, var(--bg));
color: var(--label);
overflow: clip;
box-shadow: 0 -8px 40px rgb(0 0 0 / 0.15);
transition:
height 0.45s var(--ease),
translate 0.45s var(--ease),
display 0.45s allow-discrete,
overlay 0.45s allow-discrete;
}
.sheet[data-detent="large"] {
--⚓️-mult-y: 1; /* .large detent: height = window.height − (safeTop + 10) */
--⚓️-offset-y: calc(-1 * (var(--safe-top) + 10px));
}
.sheet:not(:popover-open) { translate: 0 100%; }
@starting-style { .sheet:popover-open { translate: 0 100%; } }
.sheet::backdrop { background: rgb(0 0 0 / 0.3); transition: background 0.45s, display 0.45s allow-discrete, overlay 0.45s allow-discrete; }
@starting-style { .sheet:popover-open::backdrop { background: rgb(0 0 0 / 0); } }
/* Regular width: a centred form sheet instead of a bottom sheet. */
.window[data-size="regular"] .sheet {
width: min(540px, 90vw);
height: min(620px, 85dvh);
border-radius: 14px;
box-shadow: 0 24px 80px rgb(0 0 0 / 0.3);
}
.window[data-size="regular"] .sheet:not(:popover-open) { translate: 0 40px; opacity: 0; }
.window[data-size="regular"] .sheet { transition: translate 0.35s var(--ease), opacity 0.25s, display 0.35s allow-discrete, overlay 0.35s allow-discrete; }
@starting-style { .window[data-size="regular"] .sheet:popover-open { translate: 0 40px; opacity: 0; } }
.window[data-size="regular"] .grabber { display: none; }
.s-safe { margin: 0 var(--safe-right) var(--safe-bottom) var(--safe-left); }
.window[data-size="regular"] .s-safe { margin: 0; }
.grabber {
z-index: 2;
width: 36px;
height: 5px;
border-radius: 3px;
background: var(--label-3);
touch-action: none;
}
.grabber::before { content: ""; position: absolute; inset: -12px -40px; } /* 44pt-ish hit target */
.sheet [data-⚓️-align-self="center"] { margin-block: 5px auto; }
.s-head { display: flex; align-items: start; justify-content: space-between; padding: 22px 16px 10px 20px; touch-action: none; }
.s-head h2 { margin: 0; font-size: 20px; font-weight: 700; }
.s-close {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--fill);
color: var(--label-2);
font-size: 14px;
}
.s-close svg { stroke-width: 3; }
.s-list { margin-inline: 16px; background: var(--sheet-cell, var(--cell)); }
.check-cell { cursor: pointer; }
.check-cell .art { width: 32px; border-radius: 4px; }
.check-cell span:nth-child(2) { flex: 1; }
.check-cell input { appearance: none; margin: 0; width: 22px; height: 22px; border-radius: 50%; border: 1.5px solid var(--label-3); cursor: pointer; }
.check-cell input:checked { border: 0; background: var(--tint) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 12 4 4 8-8'/%3E%3C/svg%3E") center / 14px no-repeat; }
.s-hint { margin: 2px 0 0; font-size: 13px; color: var(--label-2); }
.window[data-size="regular"] .s-hint { display: none; }
/* inside the desktop phone frame the bottom sheet follows the frame's corner radius */
@media (min-width: 768px) { .window[data-size="compact"] .sheet { border-radius: 12px 12px 54px 54px; } }
.s-done {
height: 50px;
margin: 0 20px 16px;
border-radius: 14px;
background: var(--tint);
color: #fff;
font-size: 17px;
font-weight: 600;
}
/* ---------- inspector + highlight ---------- */
.inspector {
width: min(360px, calc(100vw - 32px));
max-height: min(56dvh, 520px);
margin: 0 0 10px;
padding: 0;
border: 0;
border-radius: 14px;
overflow: auto;
background: rgb(20 20 24 / 0.9);
backdrop-filter: blur(20px);
color: #e6e6eb;
box-shadow: 0 0 0 0.5px rgb(255 255 255 / 0.15), 0 16px 40px rgb(0 0 0 / 0.35);
font: 12px/1.45 ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
.inspector header {
position: sticky;
top: 0;
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 10px 12px 8px;
background: rgb(20 20 24 / 0.95);
font-family: system-ui, sans-serif;
font-size: 13px;
border-bottom: 0.5px solid rgb(255 255 255 / 0.12);
}
.trait { color: #9a9aa2; font-size: 11px; }
.i-item {
display: block;
width: 100%;
padding: 7px 12px;
text-align: start;
font: inherit;
border-bottom: 0.5px solid rgb(255 255 255 / 0.07);
}
.i-item:hover, .i-item:focus-visible, .i-item[aria-current="true"] { background: rgb(10 132 255 / 0.22); outline: none; }
.i-item b { display: block; color: #64d2ff; font-weight: 600; }
.i-item span { display: block; white-space: pre-wrap; color: #d0d0d6; }
.i-item em { font-style: normal; color: #ff9f0a; }
.i-item .attrs { color: #8e8e93; font-size: 11px; margin-top: 2px; }
.hl-box {
margin: 0;
padding: 0;
border: 1.5px solid var(--hl);
border-radius: 4px;
background: rgb(10 132 255 / 0.12);
pointer-events: none;
}
.hl-label {
margin: 4px 0;
padding: 3px 7px;
border: 0;
border-radius: 6px;
background: var(--hl);
color: #fff;
font: 600 11px/1.3 ui-monospace, Menlo, Consolas, monospace;
pointer-events: none;
white-space: nowrap;
}
/* X-ray: every named anchor inside the app gets a dashed outline while inspecting. */
.window[data-xray] .app [data-⚓️-name] { outline: 1px dashed rgb(10 132 255 / 0.55); outline-offset: -1px; }
.window[data-xray] .app .guide[data-⚓️-name] { outline-color: rgb(255 159 10 / 0.7); }
/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
.app, .content, .app > *, .sheet, .sheet::backdrop, .window[data-size="regular"] .sheet { transition: opacity 0.2s, display 0.2s allow-discrete, overlay 0.2s allow-discrete; }
.sheet:not(:popover-open), .window[data-size="regular"] .sheet:not(:popover-open) { translate: none; opacity: 0; }
@starting-style { .sheet:popover-open, .window[data-size="regular"] .sheet:popover-open { translate: none; opacity: 0; } }
.window[data-size="compact"] .app[data-pushed] :is(.content, .nav-title, .nav-action, .bar-top) { translate: none; }
.window:has(.sheet:popover-open[data-detent="large"])[data-size="compact"] .app { scale: none; }
}// State only. Each view's constraints are data-⚓️-* attributes; this file swaps which set
// is active (size class, push, extend-under-bars, sheet detent) exactly like
// NSLayoutConstraint.deactivate(old) / .activate(new). No measuring, no position maths.
const P = "data-⚓️-";
const win = document.querySelector(".window");
const app = document.querySelector(".app");
const el = Object.fromEntries([...document.querySelectorAll("[data-key]")].map((n) => [n.dataset.key, n]));
const sheet = el.sheet;
const inspector = document.getElementById("inspector");
const hlBox = document.querySelector(".hl-box");
const hlLabel = document.querySelector(".hl-label");
const state = { size: "compact", pushed: false, extend: false, detent: "medium", forceCompact: false };
const wide = matchMedia("(min-width: 768px)");
// ---- constraint sets: attributes to activate + the Auto Layout they express ----
// Keys are attribute suffixes (P + key); "" = boolean form.
function constraints(s) {
const R = s.size === "regular";
const c = {};
const set = (key, name, attrs, lines) => (c[key] = { name, attrs, lines });
set("screen", "view (superview bounds)", { "pin-all": "" }, ["view.edges = window.edges"]);
set("safe", "view.safeAreaLayoutGuide", { "pin-all": "" }, [
"safeArea.top = view.top + safeAreaInsets.top",
"safeArea.bottom = view.bottom − safeAreaInsets.bottom",
"safeArea.leading/trailing = view ± insets",
]);
set("primary", R ? "split.primaryColumn" : "primaryColumn (= view)",
R ? { to: "--screen", "sync-width": "", "pin-y": "", "pin-leading": "" } : { "pin-all": "" },
R ? ["primary.leading = view.leading", "primary.top/bottom = view.top/bottom",
"primary.width = 0.34 × view.width @750", "primary.width ≥ 300 @1000", "primary.width ≤ 380 @1000"]
: ["primary.edges = view.edges"]);
set("secondary", R ? "split.secondaryColumn" : "secondaryColumn (unused)",
R ? { after: "--primary", "pin-trailing": "", "pin-y": "" } : { "pin-all": "" },
R ? ["secondary.leading = primary.trailing", "secondary.trailing = view.trailing", "secondary.top/bottom = view.top/bottom"]
: ["secondary.edges = view.edges"]);
set("nav", "navigationBar", { to: "--safe", "align-top": "", "stretch-x": "--primary" }, [
"navBar.top = safeArea.top",
"navBar.leading = primary.leading",
"navBar.trailing = primary.trailing",
"navBar.height = 44",
]);
set("navTitle", "navigationItem.titleView", { to: "--nav", "center-all": "" }, [
"title.centerX = navBar.centerX",
"title.centerY = navBar.centerY",
"title.width ≤ navBar.width − 200 // clears the bar buttons",
]);
set("navAction", "rightBarButtonItem", { to: "--nav", "align-trailing": "", "align-y": "center" }, [
"addButton.trailing = navBar.trailing − 8",
"addButton.centerY = navBar.centerY",
"addButton.width ≥ 44, height ≥ 44",
]);
set("barTop", "navBar background", { "pin-top": "", "stretch-x": "--primary", "align-bottom": "--nav" }, [
"navBarBG.top = view.top // under the status bar",
"navBarBG.bottom = navBar.bottom",
"navBarBG.leading/trailing = primary",
]);
set("tabbar", R ? "tabBar (floating, iPadOS)" : "tabBar",
R ? { to: "--secondary", "align-x": "center", "align-top": "--safe" }
: { to: "--safe", "align-bottom": "", "stretch-x": "--screen" },
R ? ["tabBar.centerX = secondary.centerX", "tabBar.top = safeArea.top", "tabBar.height = 44", "tabs: hugging (intrinsic width)"]
: ["tabBar.bottom = safeArea.bottom", "tabBar.leading/trailing = view", "tabBar.height = 49", "tabs: distribution = .fillEqually"]);
set("barBottom", "tabBar background", { "align-top": "--tabbar", "pin-bottom": "", "stretch-x": "--screen" },
R ? ["(hidden in regular width)"]
: ["tabBarBG.top = tabBar.top", "tabBarBG.bottom = view.bottom // under the home indicator", "tabBarBG.leading/trailing = view"]);
const top = s.extend ? { "pin-top": "" } : { below: "--nav" };
const bottom = s.extend ? { "pin-bottom": "" } : R ? { "align-bottom": "--primary" } : { above: "--tabbar" };
set("content", "scrollView", { ...top, ...bottom, "stretch-x": "--primary" }, [
s.extend ? "scrollView.top = view.top // extendedLayoutIncludesOpaqueBars" : "scrollView.top = navBar.bottom",
s.extend ? "scrollView.bottom = view.bottom" : R ? "scrollView.bottom = primary.bottom" : "scrollView.bottom = tabBar.top",
"scrollView.leading/trailing = primary",
"cells.leading/trailing = layoutMargins (16)",
]);
set("detail", R ? "secondary viewController" : "pushed viewController",
R ? { fill: "--secondary" }
: { to: "--screen", [s.pushed ? "align-leading" : "side"]: s.pushed ? "" : "after", "sync-width": "", "pin-top": "", above: "--tabbar" },
R ? ["detail.edges = secondary.edges"]
: [s.pushed ? "detail.leading = view.leading // pushed" : "detail.leading = view.trailing // side=after, off-screen",
"detail.width = view.width",
"detail.top = view.top",
"detail.bottom = tabBar.top"]);
set("dSafe", "detail.safeAreaLayoutGuide", { "pin-all": "" }, ["dSafe.top = detail.top + safeAreaInsets.top", "dSafe.bottom = detail.bottom"]);
set("dNav", "detail navigationBar", { to: "--d-safe", "align-top": "", "pin-x": "" }, ["dNav.top = dSafe.top", "dNav.leading/trailing = detail", "dNav.height = 44"]);
set("dBack", "backBarButtonItem", { to: "--d-nav", "align-leading": "", "align-y": "center" },
R ? ["(hidden: split view shows both columns)"] : ["back.leading = dNav.leading + 4", "back.centerY = dNav.centerY"]);
set("dTitle", "detail title", { to: "--d-nav", "center-all": "" }, ["dTitle.center = dNav.center", "dTitle.width ≤ dNav.width − 200", "alpha follows albumTitle scroll-out"]);
set("dMore", "detail rightBarButtonItem", { to: "--d-nav", "align-trailing": "", "align-y": "center" }, ["more.trailing = dNav.trailing − 8", "more.centerY = dNav.centerY"]);
set("dBar", "detail navBar background", { "pin-top": "", "pin-x": "", "align-bottom": "--d-nav" }, ["dNavBG.top = detail.top", "dNavBG.bottom = dNav.bottom"]);
set("dContent", "detail scrollView", { below: "--d-nav", "pin-x": "", "pin-bottom": "" }, ["dScroll.top = dNav.bottom", "dScroll.bottom = detail.bottom", "dScroll.leading/trailing = detail"]);
set("fab", "inspector button",
R ? { to: "--safe", "align-bottom": "", "align-trailing": "" } : { to: "--tabbar", above: "", "align-trailing": "--safe" },
R ? ["fab.bottom = safeArea.bottom − 20", "fab.trailing = safeArea.trailing − 20"]
: ["fab.bottom = tabBar.top − 12", "fab.trailing = safeArea.trailing − 16"]);
set("statusBar", "status bar (simulated)", { "pin-top": "", "pin-x": "", above: "--safe" }, ["statusBar.top = view.top", "statusBar.bottom = safeArea.top"]);
set("island", "Dynamic Island (simulated)", { to: "--screen", "align-top": "", "align-x": "center" }, ["island.top = view.top + 11", "island.centerX = view.centerX"]);
set("home", "home indicator (simulated)", { to: "--screen", "align-bottom": "", "align-x": "center" }, ["home.bottom = view.bottom − 8", "home.centerX = view.centerX"]);
set("sheet", R ? "formSheet" : `sheet (.${s.detent} detent)`,
R ? { to: "--window", "center-all": "" } : { to: "--window", "align-bottom": "", "stretch-x": "", "sync-height": "" },
R ? ["sheet.center = window.center", "sheet.width = 540 @750, ≤ 0.9 × window.width", "sheet.height = 620 @750, ≤ 0.85 × window.height"]
: ["sheet.bottom = window.bottom", "sheet.leading/trailing = window",
s.detent === "large" ? "sheet.height = 1.0 × window.height − (safeTop + 10)" : "sheet.height = 0.5 × window.height"]);
set("sSafe", "sheet.safeAreaLayoutGuide", { "pin-all": "" }, ["sSafe.bottom = sheet.bottom − safeAreaInsets.bottom"]);
set("grabber", "grabber", { "pin-top": "", "pin-x": "", "align-self": "center" }, ["grabber.top = sheet.top + 5", "grabber.centerX = sheet.centerX", "grabber.size = 36 × 5"]);
set("sDone", "Done button", { to: "--s-safe", "align-bottom": "", "stretch-x": "" }, ["done.bottom = sSafe.bottom − 16", "done.leading/trailing = sSafe ± 20", "done.height = 50"]);
return c;
}
// Layout attributes the swap owns (everything except identity/behaviour attributes).
const KEEP = new Set(["name", "transition", "root", "scope", "scope-name", "try", "morph-from"]);
function activate(node, attrs) {
for (const a of [...node.attributes]) {
if (a.name.startsWith(P) && !KEEP.has(a.name.slice(P.length))) node.removeAttribute(a.name);
}
for (const [k, v] of Object.entries(attrs)) node.setAttribute(P + k, v);
}
let current = constraints(state);
function apply() {
state.size = wide.matches && !state.forceCompact ? "regular" : "compact";
win.dataset.size = state.size;
current = constraints(state);
for (const [key, c] of Object.entries(current)) if (el[key]) activate(el[key], c.attrs);
const R = state.size === "regular";
app.toggleAttribute("data-pushed", !R && state.pushed);
app.toggleAttribute("data-extend", state.extend);
el.detail.inert = !R && !state.pushed;
sheet.dataset.detent = state.detent;
if (R && !document.querySelector(".album-row[aria-current]")) showAlbum(document.querySelector(".album-row"));
if (!R && !state.pushed) document.querySelector(".album-row[aria-current]")?.removeAttribute("aria-current");
renderInspector();
}
// ---- album detail content (state, not layout) ----
function showAlbum(row) {
document.querySelectorAll(".album-row[aria-current]").forEach((r) => r.removeAttribute("aria-current"));
row.setAttribute("aria-current", "true");
const d = row.dataset;
el.detail.style.setProperty("--album-hue", d.hue);
el.detail.querySelector(".album-title").textContent = d.title;
el.detail.querySelector(".album-artist").textContent = d.artist;
el.detail.querySelector(".album-meta").textContent = `Alternative · ${d.year} · Lossless`;
el.dTitle.textContent = d.title;
const tracks = d.tracks.split("|");
el.detail.querySelector(".tracks").replaceChildren(...tracks.map((t) => Object.assign(document.createElement("li"), { textContent: t })));
el.detail.querySelector(".tracks-foot").textContent = `${tracks.length} songs, ${tracks.length * 4} minutes`;
el.dContent.scrollTop = 0;
}
document.querySelector(".albums").addEventListener("click", (e) => {
const row = e.target.closest(".album-row");
if (!row) return;
showAlbum(row);
if (state.size === "compact") { state.pushed = true; apply(); el.dBack.focus({ preventScroll: true }); }
});
function pop() {
if (!state.pushed) return;
const row = document.querySelector(".album-row[aria-current]");
state.pushed = false;
apply();
row?.focus({ preventScroll: true });
}
el.dBack.addEventListener("click", pop);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && state.pushed && !sheet.matches(":popover-open")) pop();
});
// interactive-pop stand-in: a swipe from the leading edge pops (threshold only, no tracking)
let edge = null;
el.detail.addEventListener("pointerdown", (e) => {
const r = el.detail.getBoundingClientRect();
edge = state.pushed && e.clientX - r.left < 24 ? e.clientX : null;
});
el.detail.addEventListener("pointerup", (e) => { if (edge !== null && e.clientX - edge > 60) pop(); edge = null; });
// ---- tabs ----
el.tabbar.addEventListener("click", (e) => {
const tab = e.target.closest(".tab");
if (!tab) return;
el.tabbar.querySelectorAll(".tab").forEach((t) => t.setAttribute("aria-pressed", String(t === tab)));
document.querySelectorAll(".panel").forEach((p) => (p.hidden = p.dataset.panel !== tab.dataset.tab));
el.navTitle.textContent = tab.querySelector("span").textContent;
el.content.scrollTop = 0;
state.pushed = false;
apply();
});
// ---- options ----
document.getElementById("opt-sim").addEventListener("change", (e) => win.toggleAttribute("data-sim", e.target.checked));
document.getElementById("opt-extend").addEventListener("change", (e) => { state.extend = e.target.checked; apply(); });
document.getElementById("opt-compact").addEventListener("change", (e) => { state.forceCompact = e.target.checked; apply(); });
wide.addEventListener("change", apply);
// ---- sheet: presentation + detents ----
function present(title) {
sheet.querySelector("#sheet-title").textContent = title;
state.detent = "medium";
apply();
sheet.showPopover();
}
el.navAction.addEventListener("click", () => present("Playlists"));
el.dMore.addEventListener("click", () => present("Add to a Playlist"));
function setDetent(d) { state.detent = d; apply(); }
el.grabber.addEventListener("click", () => setDetent(state.detent === "medium" ? "large" : "medium"));
// drag on the grabber/header: direction picks the next detent (no drag tracking maths)
let dragY = null;
for (const handle of [el.grabber, sheet.querySelector(".s-head")]) {
handle.addEventListener("pointerdown", (e) => { dragY = e.clientY; });
}
sheet.addEventListener("pointerup", (e) => {
if (dragY === null) return;
const dy = e.clientY - dragY;
dragY = null;
if (dy < -30) setDetent("large");
else if (dy > 30) state.detent === "large" ? setDetent("medium") : sheet.hidePopover();
});
sheet.addEventListener("toggle", (e) => { if (e.newState === "open") renderInspector(); });
// ---- constraints inspector (X-ray) ----
const LABEL = { "pin-all": "pin-all", "center-all": "center-all" };
function renderInspector() {
if (!inspector.matches(":popover-open")) return;
inspector.querySelector(".trait").textContent =
`horizontalSizeClass = .${state.size}${state.pushed && state.size === "compact" ? " · pushed" : ""}`;
const groups = state.size === "regular"
? ["primary", "secondary", "nav", "content", "tabbar", "detail", "dNav", "dContent", "fab", "sheet", "safe"]
: ["safe", "nav", "navTitle", "navAction", "barTop", "content", "tabbar", "barBottom", "detail", "dNav", "dBack", "dContent", "fab", "sheet", "grabber", "sDone", "statusBar", "island", "home"];
const list = inspector.querySelector(".i-list");
const activeKey = list.querySelector("[aria-current]")?.dataset.for;
list.replaceChildren(...groups.map((key) => {
const c = current[key];
const b = document.createElement("button");
b.className = "i-item";
b.dataset.for = key;
if (key === activeKey) b.setAttribute("aria-current", "true");
const attrs = Object.entries(c.attrs).map(([k, v]) => (v === "" ? k : `${k}="${v}"`)).join(" ");
b.innerHTML = `<b></b><span></span><span class="attrs"></span>`;
b.children[0].textContent = c.name;
b.children[1].textContent = c.lines.join("\n");
b.children[2].textContent = attrs;
return b;
}));
if (activeKey) highlight(activeKey);
}
// A named anchor per highlighted view, so the box/label can constrain to it too.
function anchorFor(key) {
const n = el[key];
if (n.hasAttribute(P + "name")) return n.getAttribute(P + "name");
n.setAttribute(P + "name", `--hl-${key}`); // borrow an anchor name for the X-ray only
return `--hl-${key}`;
}
function highlight(key) {
if (!key || !el[key]) { hlBox.hidePopover?.(); hlLabel.hidePopover?.(); return; }
const name = anchorFor(key);
hlBox.setAttribute(P + "fill", name);
hlLabel.setAttribute(P + "to", name);
const side = el[key].getAttribute(P + "side");
hlLabel.textContent = `${current[key].name}${side ? ` · side=${side}` : ""}`;
if (!hlBox.matches(":popover-open")) hlBox.showPopover();
if (!hlLabel.matches(":popover-open")) hlLabel.showPopover();
}
const list = inspector.querySelector(".i-list");
const onHover = (e) => {
const item = e.target.closest(".i-item");
if (!item) return;
list.querySelectorAll("[aria-current]").forEach((n) => n.removeAttribute("aria-current"));
item.setAttribute("aria-current", "true");
highlight(item.dataset.for);
};
list.addEventListener("pointerover", onHover);
list.addEventListener("focusin", onHover);
list.addEventListener("click", onHover);
el.fab.addEventListener("click", () => {
const open = !inspector.matches(":popover-open");
inspector.togglePopover(open);
el.fab.setAttribute("aria-expanded", String(open));
win.toggleAttribute("data-xray", open);
if (open) renderInspector();
else { hlBox.hidePopover(); hlLabel.hidePopover(); }
});
apply();data-⚓️-abovedata-⚓️-align-bottomdata-⚓️-align-leadingdata-⚓️-align-selfdata-⚓️-align-topdata-⚓️-align-trailingdata-⚓️-align-xdata-⚓️-align-ydata-⚓️-belowdata-⚓️-center-alldata-⚓️-filldata-⚓️-namedata-⚓️-pin-alldata-⚓️-pin-bottomdata-⚓️-pin-topdata-⚓️-pin-xdata-⚓️-rootdata-⚓️-sidedata-⚓️-stretch-xdata-⚓️-sync-heightdata-⚓️-sync-widthdata-⚓️-todata-⚓️-transitiondata-⚓️-try