/* ============================================================ ROOY · Shared design tokens & primitives ─────────────────────────────────────────────────────────── Type hierarchy maps to Apple HIG roles, tuned for Simplified Chinese (PingFang SC). Each token's role is fixed — DO NOT repurpose with inline fontSize overrides. Minimum legible size 11pt (HIG floor for body-glyph text). T.title 34/40 bold Large Title (page hero, ≤1/screen) T.h1 22/28 semi Title 2 (top-level section) T.h2 20/25 semi Title 3 (card title, big group) T.headline 17/24 semi Headline (emphasis / nav title) T.body 17/24 reg Body (single-line reading) T.para 17/26 reg Paragraph body (≥2-line CN reading) T.subhead 15/22 reg Subhead (UI default, short labels) T.footnote 13/18 reg Footnote (metadata, captions) T.eyebrow 13/18 med Section / kicker label — no uppercase, no tracking (CN-iOS). Editorial labels with uppercase + tracking allowed only in 08 season-b, styled inline there. T.caption1 12/16 reg Timestamps, smallest UI T.caption2 11/14 reg Floor — map labels / calendar weekday; never use for content the user reads. Number scale (upright by default, italic opt-in once per screen): N.hero weight 800 · tabular nums — sized inline N.italic weight 900 · italic · single anchor — sized inline N.base weight 700 · plain stat — sized inline N.reg default weight · tabular num utility — sized inline ============================================================ */ const R = { bg: 'var(--r-bg)', bg2: 'var(--r-bg2)', /* card — pure-white surface for inset cards / floating embed cards / list-group containers, sitting on the near-white paper bg. Resolves via CSS var so global palette tweaks still cascade. Don't hardcode #FFFFFF — use R.card so future surface changes stay in one place. */ card: 'var(--r-card)', /* Ink scale — STRICT role mapping. Pick by what the text MEANS, not how dark you want it. ink — PRIMARY CONTENT. Headings, body paragraphs, key numerals, list-cell titles, link-state text, button-on-paper text. ink2 — SECONDARY BODY. Use for: · long-form CN reading (T.para multi-line) where full ink reads too dense · the "value" half of a labeled-value pair where eyebrow labels in ink3 and the value sits between · soft destructive button text (登出 / 全部断开 / 退出上课) · season-b editorial stat / record row labels Note: inline EMPHASIS within ink body (e.g. "已经到了平台期") is fontWeight: 600 + R.ink — not ink2. Don't reach for ink2 to bold-emphasize; reach for weight. ink3 — METADATA. Captions (T.caption1/2), footnotes, eyebrows on paper, placeholders, secondary sublabels, "次" "天" units. ink4 — CHROME. Chevrons, dividers, disabled state, hint glyphs, separator characters ("/" "|" between fields). Never use for any text the user reads for content. */ /* Ink scale in OKLCH — perceptually uniform stops so the 4-step ramp reads as evenly spaced contrast levels (hex values were hand-tuned; oklch makes the rhythm explicit). */ ink: 'oklch(17% 0.005 250)', /* near-black #0E1116 */ ink2: 'oklch(33% 0.005 270)', /* #3C3C43 */ ink3: 'oklch(55% 0.01 270)', /* #76767E */ ink4: 'oklch(76% 0.005 280)', /* #B5B5BA */ hair: 'oklch(17% 0.005 250 / 0.07)', hair2: 'oklch(17% 0.005 250 / 0.13)', /* iOS tertiarySystemFill — used on search fields, filter chips, segmented controls. One canonical token so all "neutral interactive grey" surfaces match. */ systemFill: 'oklch(55% 0.005 270 / 0.12)', /* dark-surface label hierarchy (text on ink hero cards) — mirrors iOS HIG dark-mode label opacities (1.0 / 0.6 / 0.3 / 0.18). */ inkD: 'oklch(100% 0 0)', /* pure white */ inkD2: 'oklch(100% 0 0 / 0.65)', inkD3: 'oklch(100% 0 0 / 0.40)', inkD4: 'oklch(100% 0 0 / 0.18)', hairD: 'oklch(100% 0 0 / 0.08)', data: 'var(--r-data)', rooy: 'var(--r-rooy)', gold: 'var(--r-gold)', alert: 'oklch(60% 0.21 24)', /* warning red #D93B3B */ /* Tinted ROOY-color surfaces — use these instead of hardcoding rgba() so palette tweaks (moss / cyan / teal / forest / spruce) propagate to bubbles, ambient glows, and active-state pills. */ rooyTint07: 'color-mix(in srgb, var(--r-rooy) 7%, transparent)', rooyTint08: 'color-mix(in srgb, var(--r-rooy) 8%, transparent)', rooyTint12: 'color-mix(in srgb, var(--r-rooy) 12%, transparent)', rooyTint16: 'color-mix(in srgb, var(--r-rooy) 16%, transparent)', rooyGlow: 'color-mix(in srgb, var(--r-rooy) 65%, transparent)', }; const FAM_NUM = '"SF Pro Display", -apple-system, "Helvetica Neue", system-ui, sans-serif'; const FAM_TEXT = '"PingFang SC", -apple-system, "SF Pro Text", "Helvetica Neue", system-ui, sans-serif'; /* T.* 用 HIG 标准 lineHeight(拉丁字符 ratio 1.29-1.41 紧凑)—— 默认假设单行 / 短文本场景(UI label, list cell, button text)。 T.para 是给"多行中文段落阅读"opt-in 用的:17/26 ≈ 1.53,比 HIG 宽出 ~25%,对应中文方块字所需呼吸量。short text 用 T.body 保持 紧凑 cell 密度,长段落 (≥2 行 observation / 介绍语) 才换 T.para。 */ const T = { title: { fontFamily: FAM_TEXT, fontSize: 34, lineHeight: '40px', fontWeight: 700, letterSpacing: '-0.02em' }, h1: { fontFamily: FAM_TEXT, fontSize: 22, lineHeight: '28px', fontWeight: 600, letterSpacing: '-0.01em' }, h2: { fontFamily: FAM_TEXT, fontSize: 20, lineHeight: '25px', fontWeight: 600, letterSpacing: '-0.01em' }, headline: { fontFamily: FAM_TEXT, fontSize: 17, lineHeight: '24px', fontWeight: 600 }, body: { fontFamily: FAM_TEXT, fontSize: 17, lineHeight: '24px', fontWeight: 400 }, para: { fontFamily: FAM_TEXT, fontSize: 17, lineHeight: '26px', fontWeight: 400 }, subhead: { fontFamily: FAM_TEXT, fontSize: 15, lineHeight: '22px', fontWeight: 400 }, footnote: { fontFamily: FAM_TEXT, fontSize: 13, lineHeight: '18px', fontWeight: 400 }, caption1: { fontFamily: FAM_TEXT, fontSize: 12, lineHeight: '16px', fontWeight: 400 }, caption2: { fontFamily: FAM_TEXT, fontSize: 11, lineHeight: '14px', fontWeight: 400 }, eyebrow: { fontFamily: FAM_TEXT, fontSize: 13, lineHeight: '18px', fontWeight: 500 }, }; const N = { hero: { fontFamily: FAM_NUM, fontWeight: 800, fontFeatureSettings: '"tnum" 1', letterSpacing: '-0.02em' }, italic: { fontFamily: FAM_NUM, fontWeight: 900, fontStyle: 'italic', fontFeatureSettings: '"tnum" 1', letterSpacing: '-0.02em' }, base: { fontFamily: FAM_NUM, fontWeight: 700, letterSpacing: '-0.02em' }, reg: { fontFamily: FAM_NUM, fontFeatureSettings: '"tnum" 1', fontVariantNumeric: 'tabular-nums' }, }; /* Spacing — 4-pt iOS grid. Use these wherever possible so layout intent is legible: gap, padding, margin should snap to a scale step. Values between two steps (6/10/14/18/22/26) are still OK when a 4-step jump is too coarse, but invent only on purpose. */ const S = { s1: 4, // hairline gap, tight chip-to-chip s2: 8, // default inline gap s3: 12, // tight card padding, list-cell vertical s4: 16, // standard card padding, screen edge padding s5: 20, // section padding inside screens s6: 24, // hero-card padding, between-section breath s7: 32, // section-to-section breath s8: 40, // major page-section breath s9: 48, // page-level rhythm }; /* Border radius — anchored scale. card/sheet/hero/pill are the only meaningful surfaces; pick by SURFACE TYPE, not by aesthetic. bubble/bubbleU are asymmetric — the 6px corner is the "tail" pointing back at the speaker; never round all four corners or the bubble loses its ROOY ↔ user direction. */ const B = { sm: 8, // small inputs, sub-pills (segmented control items) card: 14, // PrimaryCTA 主按钮(全宽 ink CTA);卡片统一走 cardL cardL: 16, // 内容卡:InsetGroup 白卡 / 浮动内容卡(LiveCard, SessionSummary) sheet: 20, // modal sheet top corners (LensSheet) hero: 24, // editorial hero cards (Home hero, history hero) sheetL: 28, // bottom sheet (ski) pill: 999, // pills, capsules, status dots bubble: '18px 18px 18px 6px', // ROOY message bubble (tail bottom-left) bubbleU:'18px 18px 6px 18px', // user message bubble (tail bottom-right) }; /* Shadow — elevation scale calibrated for iOS-native app on the current near-white paper bg (`rgb(250,250,252)`). Each token is a self-contained elevation 档位; drop + outline layers are combined in one box-shadow value (one elevation = one token), not split. Tuned weights match Apple HIG references: Wallet / Settings cards · Now Playing · Fitness Today · context menu · UISwitch thumb. ANTI-pattern: don't reach for Linear / Stripe / shadcn weights (0.18-0.22 alpha, 28-36px blur) — that's web/SaaS dashboard, not iOS. If a layer needs more presence, look at color/contrast first. */ const Shadow = { /* card — white inset card on near-white paper. Soft 1px drop + 0.5px hairline outline layered. Apple Wallet receipt recipe. Driven by `--r-card-shadow` CSS var so palette tweaks cascade. */ card: 'var(--r-card-shadow)', /* cardSoft — 悬浮白卡的柔和 drop,无 hairline 描边。比 card 更抬起 一点、比 lift 更收,用于「浮在近白底上的内容白卡」(01e Home 三卡 / 02 天气卡)。纯 drop 不描边——描边细线读着像 border(用户不要)。 */ cardSoft: '0 2px 10px oklch(17% 0.005 250 / 0.06)', /* lift — floating button (stop button 56pt, action FAB). iOS-tight drop; the button surface (ink or white) carries its own color contrast so no outline needed. */ lift: '0 4px 14px oklch(17% 0.005 250 / 0.12)', /* hero — R.ink editorial hero card (Home / Presession / History). Subtle "weight" sense — the dark surface already self-defines, shadow just confirms it sits above paper. Apple Music Now Playing / Fitness Today scale, not dashboard. */ hero: '0 6px 20px oklch(17% 0.005 250 / 0.08)', /* sheet — bottom sheet inverse (shadow points UP into content above the sheet edge). For non-modal peek sheets — modal sheets should use a dim overlay behind, not a drop on the sheet. */ sheet: '0 -6px 20px oklch(17% 0.005 250 / 0.06)', /* modal — popover / context menu / dropdown above content. iOS context menu scale (not web dropdown). Hairline outline layered for edge clarity on light paper. */ modal: '0 8px 24px oklch(17% 0.005 250 / 0.14), 0 0 0 0.5px oklch(17% 0.005 250 / 0.06)', /* alert — full-screen-center modal dialog (alert / AskRooy bubble). One step heavier than `modal`: a centered dialog earns more lift than an anchored popover, but still iOS-tight — not the 16–44px web-dashboard drop the old per-screen hardcoded values had drifted into. Hairline outline layered for edge definition on paper. */ alert: '0 12px 32px oklch(17% 0.005 250 / 0.18), 0 0 0 0.5px oklch(17% 0.005 250 / 0.06)', /* thumb — UISwitch / slider thumb "draggable circle" lift used by 拨杆开关 (settings toggle 26pt) and SlideToStart 滑块 (60pt). Same recipe regardless of thumb size: the size carries the scale, the shadow only confirms "this floats a hair above its track". Matches Apple UISwitch thumb shadow spec. */ thumb: '0 2px 6px oklch(17% 0.005 250 / 0.18)', }; /* Z — z-index scale for layered surfaces. Use these instead of magic numbers so stacking stays predictable when an overlay raises another. popover — anchored dropdown / context menu (above content, below sheets) sheet — bottom sheet / action sheet scrim + panel alert — centered modal alert dialog (above sheets) fullscreen — media lightbox / full-takeover (top of the stack) */ const Z = { popover: 20, sheet: 100, alert: 150, fullscreen: 200 }; /* Scrim — the ONE canonical modal dim layer for the whole app. ink-tinted 0.42 + saturate 180% + blur 8px. Every overlay (sheet / alert / action sheet / lightbox) sits on this so "the modal layer" reads as one system, not five ad-hoc rgba recipes. Use via the `` wrapper or spread `...Scrim` onto a custom backdrop. */ const Scrim = { background: 'rgba(14, 17, 22, 0.42)', backdropFilter: 'saturate(180%) blur(8px)', WebkitBackdropFilter: 'saturate(180%) blur(8px)', }; /* Overlay keyframes — injected once globally at script load. Used by ScrimLayer (scrim fade), Sheet / ActionSheet (slide up), centered alerts (scale-in), Toast (drop-in). One shared timing language so every overlay enters the same way. */ if (typeof document !== 'undefined' && !document.getElementById('rooy-overlay-kf')) { const s = document.createElement('style'); s.id = 'rooy-overlay-kf'; s.textContent = ` @keyframes rooy-scrim-in { from { opacity: 0 } to { opacity: 1 } } @keyframes rooy-sheet-up { from { transform: translateY(100%) } to { transform: translateY(0) } } @keyframes rooy-alert-in { from { transform: scale(.92); opacity: 0 } to { transform: scale(1); opacity: 1 } } @keyframes rooy-toast-in { from { transform: translate(-50%,-14px); opacity: 0 } to { transform: translate(-50%,0); opacity: 1 } } `; document.head.appendChild(s); } /* ── iOS chrome ───────────────────────────────────────────── */ /* StatusBar — 两种渲染模式: - 默认(design canvas / phone-frame artboard 用):mock 9:41 + signal/wifi/battery chrome,让 phone artboard 看起来像真机截图。 - hide-mock 模式(prototype/index.html 用,靠 window. __ROOY_HIDE_MOCK_STATUSBAR flag 切换):只渲染一条 env(safe-area- inset-top) 高度的透明 spacer。PWA 上 iOS 自带 status bar 会 overlay 到这条 spacer 上方,刚好;桌面浏览器里 env = 0pt 直接 塌掉,content 顶到 stage 顶。 */ function StatusBar({ time = '9:41', dark = false }) { if (typeof window !== 'undefined' && window.__ROOY_HIDE_MOCK_STATUSBAR) { return (
); } const c = dark ? '#fff' : R.ink; return (
{time}
{[3, 5, 7, 9].map((h, i) => )}
); } function HomeIndicator({ dark = false }) { return (
); } /* ── Tab bar — ink-driven active state, icon-only ──────── */ /* TabBar — classic iOS bottom bar, label-less. Active state carried by ink black + filled icon (or thicker stroke for stroke-only icons). No text labels — three icons earn their own legibility. */ function TabBar({ active, onTab }) { const items = [ { id: 'home', icon: HomeIcon }, { id: 'ski', icon: SkiIcon }, { id: 'rooy', icon: RooyIcon }, ]; return (
{items.map(it => { const a = active === it.id; const Ico = it.icon; return ( ); })}
); } /* HomeIcon — house silhouette. Inactive: outline. Active: filled. */ function HomeIcon({ size = 24, active }) { return ( ); } /* SkiIcon — mountain peak + carving S-curve. The S-curve is what makes it read as "skiing" not just "terrain"; never remove it. Active fills the peak; the curve is always stroked. */ function SkiIcon({ size = 24, active }) { return ( {/* Mountain peak — sharper, single dominant peak */} {/* Carving S-curve from near apex down to lower-right */} ); } /* RooyIcon — avatar disc + ROOY-color pip outside top-right. Disc follows outline/filled active state like the other tab icons; the pip is ALWAYS filled R.rooy — it's the "ROOY is present" brand signature, do not toggle it with active state. */ function RooyIcon({ size = 24, active }) { return ( {/* Disc — outline when inactive, filled when active */} {/* ROOY pip — always R.rooy, the brand presence */} ); } /* ── Sub-page top bar ─────────────────────────────────────── */ function NavBar({ onBack, title, kicker, right }) { return (
{kicker &&

{kicker}

}

{title}

{right}
); } /* ── Primitives ───────────────────────────────────────────── */ function Dot({ size = 8, color, ring }) { return ( ); } function Hair({ color, inset }) { return
; } function TopoBG({ opacity = 0.02, color, dense = false }) { const lines = dense ? 14 : 8; return ( {Array.from({ length: lines }).map((_, i) => { const y = 80 + i * (dense ? 58 : 96); const amp = 24 + (i % 3) * 8; return ( ); })} ); } /* Big numeric. Default upright. Italic opt-in. */ function StatNumber({ value, unit, size = 64, color = R.ink, italic = false, unitColor, unitSize, baseline = true }) { const t = italic ? N.italic : N.hero; return ( {value} {unit && ( {unit} )} ); } function Bar({ pct, color = R.ink, height = 3, track }) { return (
); } function RooyLine({ text, em, style = {}, dark = false }) { return (

{text} {em && {em}}

); } /* Section title — top-level section heading (T.h1 22pt). Use when a section is its own information domain (e.g. "能力变化" in Home, "高光时刻" in Season). - `meta` inline count / range next to title ("· 本季 4 个", "3 / 8") - `subtitle` independent description line below title ("关卡通关") - `action` right-aligned ›All link */ function SectionTitle({ children, meta, subtitle, action, onAction, style = {} }) { return (

{children}

{meta && {meta}}
{action && ( )}
{subtitle &&

{subtitle}

}
); } /* Eyebrow section — sub-level grouping within a SectionTitle's domain. Used in season-b for "雪况" / "滑行模式" sub-groups. 15pt subhead + semibold for weight subordinate-but-readable. No letter-spacing — CN text doesn't need tracking, and applying it splits glyphs unnaturally. (08's English uppercase labels DO use tracking — but those are inline-styled in season-b.jsx, not via this primitive.) */ function EyebrowSection({ children, style = {} }) { return (
{children}
); } /* Inset list group — white grouped card. Use when you have a TRUE list of related items. Otherwise pour content directly onto paper. */ function InsetGroup({ children, style = {} }) { return (
{React.Children.map(children, (child, i) => ( {i > 0 && } {child} ))}
); } function InsetRow({ left, leftSub, right, rightSub, onClick, accent, accessory }) { return (
{accent && } {left}
{leftSub && (

{leftSub}

)}
{right} {rightSub && (

{rightSub}

)}
{accessory} {onClick && }
); } /* ── Reusable primitives ──────────────────────────── */ /* Chevron — `›` right-pointing disclosure glyph for list-cell / settings-row / "tap into detail" pill. SF-style 7×12 path. */ function Chevron({ size = 12, color = R.ink4, strokeWidth = 1.6, style = {} }) { const w = size * (7 / 12); return ( ); } /* PrimaryCTA — full-width ink action button ("提交 / 下一步 / 保存"). Don't override background — if it isn't ink, it isn't primary. For in-card / in-bubble actions use InlineCTA instead. */ function PrimaryCTA({ children, onClick, disabled, style = {} }) { return ( ); } /* InlineCTA — soft ink-pill action inside a card or bubble. One step below PrimaryCTA so it doesn't compete with surrounding copy. md (default) — T.subhead 600, for card CTAs (course "试一试 →") sm — T.footnote 500, for chat-bubble inline actions Arrow `→` is appended by default; pass arrow={false} to omit. */ function InlineCTA({ children, onClick, size = 'md', arrow = true, style = {} }) { const sm = size === 'sm'; return ( ); } /* TopoLines — two faint topographic wavy lines for ink hero cards. y1/y2 + viewBox parameterized so each hero can tune to its height. Stroke is hairD (white 0.08) — readable on ink without becoming wallpaper. Per design rule: 2 lines max, no grid, no fake terrain. */ function TopoLines({ y1 = 70, y2 = 130, viewBoxW = 360, viewBoxH = 260, preserveAspectRatio = 'xMidYMid slice', }) { const c1y = y1 - 14, t1y = y1 - 6; const c2y = y2 - 16, t2y = y2 - 8; return ( ); } /* ACADEMY_TABS — shared Segmented items for 05 能力 / 06 课程 / 07 成就. Single source of truth across the three Academy screens. 'review' tab 对外标签是「能力」(能力雷达),id 仍为 review。 */ const ACADEMY_TABS = [ { id: 'review', l: '能力' }, { id: 'course', l: '课程' }, { id: 'medals', l: '成就' }, ]; /* Segmented — iOS systemFill segmented control. `items` is [{id, l}]; `value` is the active id. Active: white fill + subtle lift + ink 600. Inactive: ink2 500 transparent. Reuse this for any future filter / range switcher — don't invent another. */ function Segmented({ items, value, onChange, style = {} }) { return (
{items.map(it => { const a = value === it.id; return ( ); })}
); } /* UserAvatar — ink-disc initial badge for the current user. Canonical sizes: 44 (Home top-right → settings) / 56 (settings / pairing identity row). letterSize override for other sizes. onClick → button (44pt hit area); omit → static div. */ const _USER_AVATAR_LSIZE = { 44: 15, 56: 22 }; function UserAvatar({ size = 44, letter = 'Y', letterSize, onClick, style = {} }) { const fs = letterSize ?? _USER_AVATAR_LSIZE[size] ?? Math.round(size * 0.36); const Tag = onClick ? 'button' : 'div'; return ( {letter} ); } /* DottedGrid — radial-dot bg behind charts / sparklines (review radar, ski-b/c track strip, home spotlight). One canonical look: 0.16 opacity / 18×18 grid / 1.1px dot. Wrapper div; children render on top. */ function DottedGrid({ children, style = {}, ...rest }) { return (
{children}
); } /* StatusBadge — tinted pill for short status labels. tones: rooy — "正在练 / 进行中 / ROOY 推荐" (rooyTint12 + R.rooy) neutral — "下一步 / + boosts" (systemFill + R.ink2) sizes: sm — T.eyebrow 13/500, padding 2/8 (status verbs) md — T.footnote 13/500, padding 3/10 (longer labels) */ function StatusBadge({ children, tone = 'neutral', size = 'sm', style = {} }) { const isRooy = tone === 'rooy'; const isMd = size === 'md'; return ( {children} ); } /* CCond — single column in the 02 / 02a presession weather strip (新雪 / 开放雪道 / 风速). Footnote label + N.hero-28 number + unit. */ function CCond({ l, v, u, c, lc, vSize = 28 }) { return (

{l}

{v} {u}

); } /* SensorGlyph — wearable pod silhouette. Pod outline + LED dot + status hairline, reading as "挂在滑雪靴上的小硬件" (Whoop strap / AirTag 视觉语言), not "wifi signal". ROOY-green 配对 pip 由 call site 在 glyph 外渲染。 */ function SensorGlyph({ color = R.ink }) { return ( {/* Pod body — 垂直圆角矩形, 上下对称 */} {/* LED indicator dot (top half) */} {/* Status line / button (bottom half) */} ); } /* SunGlyph — disc + 8 rays. Used in presession weather header at size 64 (R.ink3). Size param keeps it usable at smaller chrome sizes if needed. */ function SunGlyph({ size = 18, color = R.ink }) { return ( {[0, 1, 2, 3, 4, 5, 6, 7].map(i => { const a = i * Math.PI / 4; const x1 = 9 + Math.cos(a) * 5.5, y1 = 9 + Math.sin(a) * 5.5; const x2 = 9 + Math.cos(a) * 7.2, y2 = 9 + Math.sin(a) * 7.2; return ; })} ); } /* ============================================================ ROOY · Overlays (alerts / sheets / toasts / action sheets) ─────────────────────────────────────────────────────────── One Scrim, one shadow ramp (Shadow.modal → Shadow.alert), one z-index scale (Z), one entry-animation language. Compose these instead of hand-rolling a backdrop per screen. Dismiss contract: · center alert — must tap a button (no scrim dismiss), per iOS HIG · sheet / action — scrim tap + drag handle (swipe-down affordance) · toast — no action required, auto-dismiss ============================================================ */ /* ScrimLayer — the dim backdrop every modal shares. `onDismiss` undefined → scrim tap does nothing (use this for center alerts, which require a button). `align` = flex alignItems ('flex-end' for bottom sheets, 'center' for alerts). */ function ScrimLayer({ onDismiss, zIndex = Z.sheet, align = 'flex-end', children, style = {} }) { return (
{children}
); } /* SheetHandle — the iOS drag grabber. Visual affordance for swipe-to-dismiss (functional scrim-tap is the other path). */ function SheetHandle({ style = {} }) { return (
); } /* Sheet — canonical iOS bottom sheet. ScrimLayer (tap to close) + drag-handle panel with B.sheet top corners + Shadow.sheet. NO close button: scrim tap + drag handle are the two dismiss paths. Optional centered `title` under the handle. Panel slides up on mount. For non-standard layouts (tall share sheet, embedded sheet) compose ScrimLayer + SheetHandle + the tokens directly instead. */ function Sheet({ onClose, title, children, style = {} }) { return (
e.stopPropagation()} style={{ width: '100%', background: R.bg, borderTopLeftRadius: B.sheet, borderTopRightRadius: B.sheet, boxShadow: Shadow.sheet, paddingBottom: 20, animation: 'rooy-sheet-up 320ms cubic-bezier(0.2,0,0,1) both', ...style, }}> {title && (
{title}
)} {children}
); } /* Toast — transient top pill for 轻提示 (复制成功 / 已加入课程 / 已设为 目标). ✓ glyph + one line on a frosted ink pill, auto-dismiss. Sits at top center, floats above everything (Z.fullscreen). NOT a modal — no scrim, no required action. The克制 alternative to promoting every confirmation to an alert. `action` renders a trailing verb link (撤销 / 去看看). */ function Toast({ text, action, onAction, style = {} }) { return (
{text} {action && ( )}
); } /* ActionSheet — iOS bottom action sheet for destructive confirms (登出 / 断开传感器 / 删除账号). ScrimLayer + drag-handle panel; `actions` is a list of {label, destructive?, onClick}; cancel is always last and grouped apart with a gap. Replaces the old "tap a red row, no guard" pattern. */ function ActionSheet({ title, actions = [], onCancel, cancelLabel = '取消' }) { return (
e.stopPropagation()} style={{ width: '100%', padding: '0 8px 8px', animation: 'rooy-sheet-up 320ms cubic-bezier(0.2,0,0,1) both', }}> {/* action group */}
{title && (
{title}
)} {actions.map((a, i) => ( ))}
{/* cancel — grouped apart, iOS convention */}
); } Object.assign(window, { R, T, N, S, B, Shadow, Z, Scrim, StatusBar, HomeIndicator, TabBar, NavBar, Dot, Hair, TopoBG, StatNumber, Bar, RooyLine, SectionTitle, EyebrowSection, InsetGroup, InsetRow, HomeIcon, SkiIcon, RooyIcon, Chevron, PrimaryCTA, InlineCTA, TopoLines, Segmented, UserAvatar, DottedGrid, StatusBadge, CCond, SensorGlyph, SunGlyph, ScrimLayer, SheetHandle, Sheet, Toast, ActionSheet, ACADEMY_TABS, });