neuron-explorer
Qwen3.5‑4B 전용 조향형 뉴런 탐색기, 모든 MLP 뉴런을 밀어 결과를 보관·탐색
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Native-feeling controls for React web apps on the foldable iPhone (iPhone Duo): tabs, actions and menus that follow the fold, in Safari and as a Home Screen app.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Native-feeling controls for React web apps on the foldable iPhone (the "iPhone Duo"). Your page and its control strip, playing together.
npm i duet-ui
| Strip | Column | Inline |
|---|---|---|
Recorded on the iPhone Duo simulator, framed by tools/media.
On the closed Duo, iOS puts navigation in a vertical strip at the screen's edge, not a bottom tab bar. A Home Screen web app gets that strip as a safe-area inset. In a Safari tab, Safari owns the strip for its own buttons and leaves part of it empty. A web page that ignores this ends up with its own bar fighting Safari's, or its buttons under the camera.
<Duet> draws your menu (a burger opening a sidebar), actions and primary action where a native app would, picks one of three layouts (strip, column, inline) from the window geometry, keeps every control inside the space it may use, and folds what does not fit into a "…" menu. <DuetPanes> shows one screen of a path when the page is narrow and two side by side when it is wide. useDuet() tells your own code what Duet decided, including whether this looks like an iPhone Duo.
Everything is inferred from web signals (window and screen sizes, safe-area insets, display mode, user agent). iOS gives a web page no API for the device model, the fold, Safari's buttons or the Dynamic Island, so detection is a heuristic built from measurements, and column mode relies on WebKit behaviour that is not a supported API. See Limitations.
duet-ui/styles.css."use client" in the Next.js App Router).NoInfer, so TypeScript 5.4 or newer."use client";
import { useState } from "react";
import { House, Link, Plus, Search } from "lucide-react";
import { Duet, column, type DuetMenuItem } from "duet-ui";
const PLACES = [
{ id: "home", label: "Home", icon: <House size={22} /> },
{ id: "search", label: "Search", icon: <Search size={22} /> },
] as const satisfies readonly DuetMenuItem[];
type Place = (typeof PLACES)[number]["id"];
export function App() {
const [place, setPlace] = useState<Place>("home");
return (
<Duet
menu={{ items: PLACES, selected: place, onSelect: setPlace }}
actions={[{ id: "copy", label: "Copy link", icon: <Link size={22} />, onPress: () => navigator.clipboard.writeText(location.href) }]}
primary={{ id: "new", label: "New note", icon: <Plus size={22} />, onPress: create }}
>
<main style={column(680)}>
<h1>{place === "home" ? "Home" : "Search"}</h1>
</main>
</Duet>
);
}
Icons are yours, from any library (this example uses lucide-react): 21 to 22pt on round buttons match the system. as const types the menu ids, so onSelect takes setPlace without a cast. Each place keeps its own scroll position. menu={{ ..., burger: false }} drops the burger and lists the places in "…" under "Menu". Pass the page as children: it then scrolls in an inner scroller, which keeps the controls still during momentum scroll and lets the burger slide it aside. Without children the document scrolls and the controls follow it (see Column mode).
On phones and browsers that are not an iPhone Duo in Safari or a Home Screen app, the same code renders the inline layout, so you may want to render <Duet> only where useDuet().duo is true (see Server rendering).
useDuet().duo (and isDuo(signals)) is a heuristic. It is true when both hold:
screen.width x screen.height) or the window size (outerWidth x outerHeight) matches, within 1px and in either orientation, one of the sizes measured on the Duo: 466x678 (closed screen; Safari keeps reporting it as screen.* after unfolding), 669x951 (open screen) or 469x669 (a Safari split-view pane).iPhone or iPod, or it contains Macintosh and navigator.maxTouchPoints is greater than 1 (Safari's "Request Desktop Website"). Signals without a user agent, such as ones you build yourself for isDuo(), skip this check.Possible false positives: an iPad (which also sends a Mac user agent with touch) or another iOS device whose screen or window happens to match one of these sizes. Possible false negatives: anything that changes the reported sizes, such as a different display zoom setting, a future iOS release, or a window size that was not measured. Use it to switch to a Duo layout, not as proof of the device.
Duet re-reads the signals on resize, orientation change (including screen.orientation change: turning from one landscape to the other keeps the window size), visual viewport resize, pageshow and visibilitychange (so a page handed from the in-app preview to a Safari tab re-reads). The window size and insets settle late (after a fold outerWidth can land up to ~2s after the viewport, with no event), so after each event Duet polls every 100ms until the sizes hold for 500ms, re-reading on every change, for at most 2s. It picks the first mode that matches:
| Order | Mode | Conditions (all must hold) | Controls |
|---|---|---|---|
| 1 | strip | Running as a Home Screen app (navigator.standalone or display-mode: standalone) on a Duo (duo true), and the left or right safe-area inset is at least 76px (the Duo's strip is 84; an iPhone's landscape sensor inset is 59, an Android cutout 62). The right side wins if both are. Also the in-app Safari preview turned sideways in its default or full style (below): closed screen, 678x466 window, an 84px safe-area inset on the turned side. | In the strip iOS gives the app, beside the page (in the preview, in the inset below its top bar) |
| 2 | column | Not a Home Screen app; not inside an iframe; not the in-app Safari preview turned sideways (below); the browser is Safari (below); outerWidth - innerWidth is 80 to 88px; outerHeight - innerHeight is 68 to 102px (not checked in the in-app preview, whose top bar compacts on scroll); portrait (innerHeight > innerWidth), or landscape in a window the size of the closed screen (678x466 within 1px) with screen.orientation.angle 90 or 270; the page is not zoomed (visualViewport.scale 1); the oscillation guard has not paused column mode; and either the window is a split-view pane (outerWidth x outerHeight 469x669 within 1px, iOS user agent) or duo is true | Painted into the part of Safari's column (or the in-app preview's) its own buttons leave empty, so the page keeps its full width |
| 3 | inline | Everything else: unfolded at full width, landscape of unknown turn, other browsers, in-app web views, the in-app Safari preview unfolded, other phones, desktops | An 84px strip inside the page, on the side the user picks |
"Safari" means the user agent has Version/x Safari/ and none of the tokens of other iOS browsers and apps (CriOS, FxiOS, EdgiOS, OPiOS, GSA/, Ddg/); an in-app web view drops the Safari/ token. useDuet().browser reports the result: "safari", "chrome", "firefox", "edge", "webview" or "other". Other browsers draw their own chrome where Safari has its column, so they always get inline.
There is no fold signal. The mode follows the geometry: folding or unfolding changes the window size, and the mode follows on the next read.
How a new reading is committed:
useDuet().keyboard is true while the visual viewport is shorter than the page, and <Duet> sets data-keyboard; in column and inline mode the controls hide until it closes (it covers the lower column, and the inline rail would slide off). It counts only while a text field has focus, and until the viewport grows back once focus leaves.visualViewport.scale not 1, for example Safari zooming out when an input takes focus) the window signature is unreadable, so Duet keeps the last context.useDuet().confidence says how the context was reached: "sure" (settled signals), "settling" (the window is still moving) "held" (the last context, kept through zoom or drift) or "moving" (a turn or fold caught halfway, controls hidden).Column mode infers from the window geometry that Safari's column is there; it never sees Safari's buttons. The 80 to 88px width difference is Safari's column (84px measured), the height difference is Safari's chrome below the page.
position: fixed content stops at the page viewport (and a fixed box covering the middle of the viewport edge has its colour painted over the column, see Modals and sheets in column mode), but WebKit paints in-document content that overflows the layout viewport into Safari's column. Duet renders the controls in an absolute layer at the top of the document, widens the document by the column's width, keeps the document itself from scrolling (the page scrolls in the inner scroller) and sets touch-action: pan-y on <html> so the page does not pan sideways. This is observed WebKit behaviour, not a supported API.children the document itself scrolls, and the layer follows it on a CSS scroll timeline (animation-timeline: scroll(root)). Safari runs the timeline with the scroll, so the controls hold through momentum and through smooth programmatic scrolls (scrollIntoView({ behavior: "smooth" }), scrollTo({ behavior: "smooth" })): you need no scroll listener and no behavior: "auto" workaround. Measured on the simulator: within 1px through flings and at rest, and one frame of up to 7pt at the end of a smooth scroll (a transform set on scroll ran up to 300pt off there). The keyframe ends at scrollHeight - innerHeight, Safari's own timeline range, which follows its URL bar as it collapses. Without scroll-timeline support, or when a page reset like * { animation: none !important } removes it, Duet falls back to a transform set on scroll, which lags in momentum. Children mode stays the steadier choice.touch-action stops at that scroller. Duet pins scrollX (0, or the column width when the document spans a left column) on every scroll and sets overscroll-behavior-x: contain on all elements at zero specificity (:where()), so your own value wins.pointerdown, finds the button under the touch's coordinates and clicks it, so a column button fires on touch down.A modal or sheet on the web usually sits on a full-screen scrim: position: fixed; inset: 0. In Safari's column on the closed Duo that used to hide Duet's whole column layer, the close button included (#79).
What you see: nothing to handle. While the overlay is open, the controls stay in the column and keep working. Safari's own buttons there are unaffected either way.
Cause: Safari 26 hit-tests the middle of each viewport edge, 4px in, for a fixed or sticky box (WebKit's LocalFrameView::fixedContainerEdges). When one covers that point, Safari paints the box's colour, or plain chrome when the box has a backdrop-filter, over everything beyond that edge, Safari's column included. Duet's column layer is in-document content, so it disappears under that fill. Measured on the simulator: a scrim 3px short of the edge still hides the column, 6px short does not, and a scrim of 90% width or 40% height does not.
What Duet does: on a Duo in Safari it renders a small fixed guard (.duet-guard, 16x64px, transparent, pointer-events: none, top z-index) straddling that point on each viewport edge, in every mode, so it is there through folds and turns too. Safari's hit test lands on the guard, which is too small to count as a container, so it finds none and keeps painting the column normally. The guard takes no taps and paints nothing.
What you must do: nothing for visibility. For a native feel, pass close to <Duet> while the sheet is open, so the controls hold only its close button, and hide the sheet's own X while Duet shows its close (<html data-duet-close>, set in every mode, so the open screen shows one X too):
<Duet menu={menu} close={open ? { label: "Close", onPress: () => setOpen(false) } : undefined}>
html[data-duet-close] .sheet-x { display: none; }
The old workaround, making the scrim position: absolute in column mode, still works and is no longer needed.
Blur: keep backdrop-filter off the scrim box itself and put it on a ::before, so Safari's bar takes the dimmed colour on the open screen instead of a solid band (see Sheet scrim stops at Safari's bar):
.sheet-scrim { position: fixed; inset: 0; background: rgb(0 0 0 / 0.38); }
.sheet-scrim::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; backdrop-filter: blur(8px); }
Not covered: puts its in the browser's top layer, above any , so it still hides the column (checked on the simulator), and the page outside a modal dialog is inert anyway, so Duet's buttons could not take taps. Use a fixed scrim (or with your own scrim) when you want the column controls. A page box of your own with over the middle of the column edge also beats the guard.
Try it on the site.
A Safari split-view pane (469x669 window, 385x571 page) has Safari's column on the pane's outer edge, but no web signal says whether the pane is on the left or the right. In column mode Duet widens the document by a column on each side and draws the controls in both; each pane only shows its own column, and a tap there tells Duet which side it was. The document is kept scrolled across the left column, so a pane shows document x 84 to 469. When a split pane falls back to inline, its side is remembered separately from the full-window side, so the user can move the controls next to Safari's column once. useDuet().split is true in a split pane.
useDuet().side is the edge the controls are on. It is only detected in strip mode (from which safe-area inset is large). In column mode it is Safari's column on the closed Duo: "right" in portrait; turned sideways, the side the camera is on, "left" at angle 90 (turned anticlockwise) and "right" at 270. A left column widens the document on the left and keeps it scrolled across, as in split view. In a split pane it is nominal, since both edges are drawn. In inline mode it is the user's choice: drag the controls across the middle of the page, or use "Move controls" in "…". The choice is stored in localStorage per site (duet-side, or duet-side-split in split view). Without one, it is "right". In the in-app preview turned sideways (strip) it is the side of the safe-area inset: "left" at angle 90, "right" at 270.
Links opened from social and mail apps can open in the in-app Safari preview (SFSafariViewController). On the closed Duo the preview has a column like a Safari tab's, holding its own buttons: close and reader/menu at the top, back and a share/reload/Safari pill below. In portrait Duet uses column and puts its controls into the free gap between the reader/menu button and the back button (146pt, screen y 282 to 428, the same in the sheet and full screen on the simulator and a real Duo), so the page keeps its full width. Live Activities push close and reader/menu down (one about 45pt on a real Duo) while back stays put, and a page can't tell, so Duet assumes the worst case (DUO.island, two activities, as in a Safari tab): the gap is then about 54pt, room for one button, and none of Duet's buttons can ever sit under the preview's. That button is "…", centred in the gap, its list holding the places under "Menu", then the page's actions. Turned sideways, the preview's layout depends on its style. The sheet keeps a column on the side the Duo is turned to, with a gap of about 26pt, too small for a button: there Duet hides its controls (useDuet().hidden is "preview", the controls get visibility: hidden and no inset) instead of drawing an inline strip across the page, so the page keeps its full width and the preview's own close, back, share and "Open in Safari" stay usable. The default and full styles move every native button into a top bar (window y 24 to 68) and give the page an 84px safe-area inset on the turned side (left at 90, right at 270) that nothing draws in: Duet uses strip there, its controls in that inset from below the top bar to above the home indicator, and the page keeps its width. Turning back to portrait, or opening the page in Safari, brings the column back. A strip across page content was flagged in review and is never used. Duet sets useDuet().preview in both. Duet takes a Safari window that is not standalone to be the preview when it is either:
devicePixelRatio 3, the size earlier builds gave the preview.No Safari tab on the Duo is smaller than the screen (a split pane is 469 wide, the screen 466), and iOS has no windowed Safari or Stage Manager, so a window inside the screen is the sheet.
The verdict is sticky: a page that loaded as a tab stays a tab while its page height and insets move (scrolling collapses the URL bar to 80px of chrome), and is only re-decided when the window size, page width, turn, standalone or framing changes, or on pageshow or when the page becomes visible again. In a window's first second the verdict may still rise to preview, since the sheet's inset reads 0 on the first sample and 34 about 100ms later. A preview that stops matching becomes a tab at once.
While the Duo turns or folds, the window passes through sizes no landed window has (inner and outer orientations disagree, or the chrome around the page is not Safari's). Duet keeps the last placement and hides the controls (hidden is "moving") until the window lands and holds there for 150ms, so nothing flashes at the wrong edge mid-turn, not even the closed portrait window an unfold from landscape passes through for a frame or two.
Unfolded, the preview is a sheet centred on the open screen (653x661 wide, 653x869 tall) with its bars above and below the page and no column: Duet reads it as the preview (preview true) and goes inline on the user's side, as in full width Safari. Limits: When "Open in Safari" hands the page to a tab, the pageshow/visibilitychange re-read switches to column. The /probe page logs every signal the rule reads, every 100ms. Measurements are in docs/research-notes.md.
Column mode can feed back into the signals (for example if Safari zooms the page out to fit the wider document, the column signature disappears, inline removes the overflow, and the signature returns). If the mode changes 3 times within 2 seconds while the outer window size stays the same, Duet pauses column mode for 3 seconds, then tries again. If the loop resumes as soon as the pause lifts, the next pause doubles. A change of window size resets the guard, and changes while the window is still settling after an event (the 2s polling above, as during a fold) are not counted: they are the transition, not a loop.
Duet appends shrink-to-fit=no to every <meta name="viewport"> when duet-ui is imported, and viewport-fit=cover when <Duet> mounts, and keeps both on metas the framework adds or rewrites later. If there is no viewport meta it adds width=device-width,initial-scale=1 first. It only appends a key that is absent: an existing viewport-fit or shrink-to-fit value is left as it is. Safe-area insets only reach the page with viewport-fit=cover, so with viewport-fit=contain strip mode cannot be detected. Without shrink-to-fit=no, Safari zooms a split pane out to fit the widened document.
Each mode has a band the controls may use, and Duet keeps every control inside it, so you never position anything yourself:
primary. "…" shows whenever something is in it. Items marked folded or destructive always sit in "…", after the ones that did not fit.columnTop (the reader button's worst-case bottom plus 11pt) and its bottom above Safari's pill, opening flush with the column like Safari's own menus. A menu too tall for that band opens wholly over the page, beside the column, where it may rise to the top of the page. One too tall even there caps its height and scrolls inside. Duet sets --duet-safe-top-column on <html> for your own popovers (0 outside column mode).The server can't measure anything, so on the server and during hydration useDuet() returns a fixed snapshot: mode: "column", duo: true, browser: "safari", layout: "narrow", side: "right" (a closed Safari tab). The real values arrive right after hydration. So useDuet().duo is true in the server HTML on every device: a page that renders a Duo layout or a desktop layout from it switches once after hydration on non-Duo devices.
To choose the layout on the server, <DuoCookie> keeps a duet-duo cookie ("1" on a Duo) in step with the browser. Read the cookie on the server, render from it, and pass what you rendered as rendered. After hydration, if the browser's answer differs, <DuoCookie> updates the cookie and calls onChange (default: location.reload()), so a new device switches layout once. It renders nothing and works outside <Duet>. In the Next.js App Router:
// app/page.tsx, a server component. DuoApp and DesktopApp are your own components.
import { cookies } from "next/headers";
import { DuoCookie } from "duet-ui";
export default async function Page() {
const duo = (await cookies()).get("duet-duo")?.value === "1";
return (
<>
<DuoCookie rendered={duo} />
{duo ? <DuoApp /> : <DesktopApp />}
</>
);
}
Pass onChange={() => router.refresh()} from a client component to re-render without a reload.
useDuet() and <Duet> only know the mode once React mounts, so CSS keyed on html[data-duet-mode="column"] (a band bled into Safari's column, page controls moved there) would paint the plain layout first. Inline bootScript as the first child of <body>: it runs detect() before the first paint and, in column mode only, sets data-duet-mode="column", data-duet-boot and --duet-column, --duet-vh and --duet-inset-bottom on <html>. Every other browser and device is left untouched. Once mounted, <Duet> takes the values over, or useDuet() removes them if the page is not in the column after all.
// app/layout.tsx
import { bootScript } from "duet-ui/boot";
<html suppressHydrationWarning>
<body>
<script dangerouslySetInnerHTML={{ __html: bootScript }} />
{children}
</body>
</html>
suppressHydrationWarning on <html> because the script changes its attributes before React hydrates. --duet-column is the column's width (0px outside column mode once mounted).
<Duet>| Prop | Type | What it does |
|---|---|---|
menu | { items, selected, onSelect } | The app's places (DuetMenuItem[]). The burger heads the controls and opens them in a sidebar, sliding the page aside. Each place keeps its scroll position |
actions | DuetItem[] | Round buttons under the burger while they fit, the rest in "…". destructive: true items only show in "…", last, in red; folded: true items only in "…" |
primary | DuetPrimary | The screen's main action, a tinted round button in its own group (at the thumb inline). The first tap arms it into a 214pt labelled pill, the second runs it, see Call to action. Folds into "…" when no arrangement keeps it |
close | false | { label?, onPress } | A sheet or card is open: the controls hold only a close button until it is cleared |
appearance | "auto" | "light" | "dark" | Light or dark, like overrideUserInterfaceStyle. Default "auto": follow the system. dark (boolean) still works but is deprecated |
tint | string | Accent for the selected place, the primary button and checked menu rows, like iOS's tintColor. Default: system blue |
icons | { size?, weight?, color? } | How your icons are drawn. See Theming |
reserve | boolean | Default true: the page keeps clear of the controls (--duet-inset-*, <html> margins). false lets it run under them |
children | ReactNode | Page content, in an inner scroller so the document never scrolls and the controls stay still |
DuetItem is { id, label, icon, onPress?, badge?, badgeTone?, tint?, destructive?, folded? }: badge is a number or short text in a badge, red by default; badgeTone: "neutral" makes it grey. DuetMenuItem<T> is { id: T, label, icon?, badge?, badgeTone? }. DuetPrimary is a DuetItem plus confirm?: string | false: the armed pill's label (default label), or false to run on the first tap.
One template on every layout, the same in Safari's column, the strip, inline and split view: the burger first, then the actions while they fit, then "…", which closes the group, and the primary in its own group. Where the band shrinks, actions move into "…" one at a time, then the primary. The in-app preview's gap holds one button, so it shows only "…", whose list leads with a "Menu" section (the places), then "This page". "…" grows into its list on one interruptible spring, open and close; any tap outside closes it. The pure layout is exported (template, allocate, frame, place, menuBox, pillRect) for tests and custom chrome.
Deliberately small, like iOS: an accent, an appearance and your icons. The glass itself stays native.
<Duet appearance="auto" tint="#ff2d55" icons={{ size: 21, weight: 1.75 }} menu={...}>
tint colours only what is active: the selected place, the checked menu item, the primary button. Everything else stays ink (black in light, white in dark).appearance sets the page theme. Menus follow it. In Safari's column the buttons keep following the system, so they match Safari's own; menus open over the page, so they keep appearance.icons sizes and weighs your icons, the way the system sizes SF Symbols in a native bar: size (round buttons, groups, menus, default 22px), weight (stroke width of SVG line icons such as Lucide, default 2) and color (unselected icons, default ink). SVGs are resized directly; font icons take the size as font-size, so give them font-size: 1em or leave it unset. The chrome's own glyphs (burger, close, "…") keep their native sizes.Every prop writes a CSS custom property on .duet, so plain CSS can set the same tokens instead, globally or per appearance. These are stable:
| Token | What it is | Default |
|---|---|---|
--duet-tint | Accent: selected menu item, primary button | #0088ff |
--duet-ink | Text and icon colour | black / white |
--duet-icon | Unselected icon colour | --duet-ink |
--duet-icon-size | Icons in round buttons, groups and menus | 22px |
--duet-tab-icon-size | Unused since 0.6 (no tab pill), kept so older CSS stays valid | 24px |
--duet-icon-weight | Stroke width of SVG line icons | 2 |
--duet-badge, --duet-badge-neutral, --duet-badge-ink | Badge fills (alert, neutral) and text | red, grey, white |
--duet-solid | Opaque surface: menus in Safari's column, the badge ring | near white / near black |
--duet-frost, --duet-menu | Glass veil of controls and of menus | sampled from iOS |
--duet-sel, --duet-lift | Selection and press highlight | sampled from iOS |
:root .duet { --duet-tint: #ff2d55; --duet-icon-size: 21px; }
:root[data-lg-theme="dark"] .duet { --duet-tint: #ff6482; }
A page with one thing to do (book, buy, join) passes it as its primary, and folds what else it offers:
<Duet
actions={[
{ id: "share", label: "Share", icon: <Share />, onPress: share, folded: true },
{ id: "fav", label: "Favourite", icon: <Heart />, onPress: fav },
]}
primary={{ id: "book", label: "Book", confirm: `Book · ${credits} credits`, icon: <CalendarCheck />, onPress: book }}
/>
confirm (or label). The second tap runs onPress. A tap anywhere else disarms it. confirm: false runs on the first tap.tint on the primary overrides the accent for it alone. Change label, icon, tint or folded at any time, from state like any prop.Between pages the controls move like a native bar: a group that appears scales up out of a blur, one that goes away shrinks into one and is kept for 450ms so it can, and the rest slide to their new places on the same spring. With reduced motion, controls crossfade in place.
useDuet()Returns the current Context: mode, side, duo, browser, layout ("wide" when innerWidth is at least 600px, else "narrow"), split, preview and keyboard (when true), hidden ("moving", "preview" or "pending" when the controls are not drawn), confidence, width and height (the page viewport), stableHeight (the smallest page height seen at this window width and orientation, like CSS svh: it does not grow when Safari's URL bar collapses), plus the screen size and insets the placement uses. It works outside <Duet>.
isDuo, isIOS, isSplit, browserOf, detect, columnBand, magnet, panes, backFrom and the measured constants (DUO, INLINE_WIDTH, MAGNET, WIDE) are exported for your own signals and tests, along with isPreview. The column template is exported too: template, allocate, frame, place, menuBox, pillRect, groupHeight and TEMPLATE. duet-ui/geometry exports the geometry module alone, without React.
readSignals() returns the current Signals from the window, the same reading the chrome uses (browser only). watchSignals(cb) calls cb whenever they may have changed, with the triggers and settle polling described in Which mode, and returns the unsubscribe. Together with detect they let a page act on the mode before React mounts:
import { readSignals, watchSignals, detect } from "duet-ui";
const off = watchSignals(() => { document.documentElement.dataset.mode = detect(readSignals()).mode; });
The page spans the whole window and the controls float over it, so backgrounds run on under them and the layout is asymmetric, like a native app. Only text and buttons keep clear.
column(maxWidth, pad): a centred reading column. It centres on the whole window and moves aside from the controls only as far as it must. The duet-column class does the same in CSS (--duet-max, --duet-pad).bleed(pad): a full-width band (hero, sticky bar, list row). The band runs under the controls, its content is padded back beside them. bleed(false) runs content itself edge to edge (a map, an image). The duet-bleed class does the same in CSS.Duet sets the insets on <html> as --duet-inset-left, --duet-inset-right (the controls' width on their edge; 0 in column mode without children, see below), --duet-inset-top (28px in strip mode, for iOS's top blur in Home Screen apps, else 0) and --duet-inset-bottom (in column mode, Safari's chrome below the page: the page runs on under Safari's floating URL bar, and the scroller ends with that much space so the last content scrolls clear) and --duet-safe-top-column (in column mode, the top of the part of Safari's column clear of its back and reader buttons: keep popovers that cross the column below it). --duet-vh is stableHeight in px: size full-screen sections with min-height: var(--duet-vh, 100svh) so they keep their height while the URL bar collapses and expands. Inside <DuetPanes> each pane gets its own left and right insets: only a pane on the controls' edge is inset. <html data-duet-mode> holds the current mode for CSS.
In column mode the controls sit in Safari's column, outside the layout viewport, so a page has nothing to keep clear of. Without children Duet places the page itself, so any page, including one that never calls column() or bleed(), starts just clear of the controls, keeps centred content centred in the space left and is never clipped:
<html> margin-left of the column width and the matching negative margin-right, so the page lands in the visible part of the window instead of under the column. <html data-duet-reserve> marks this. Do not set margin on <html> yourself, and do not reset scrollX: Duet owns it.<Duet reserve={false}> opts out: the page runs under the column as before, and --duet-inset-left / --duet-inset-right hold the column's width for column(), bleed() or your own padding.With children, in inline and strip mode and in the emulated column, the page still runs under the controls and keeps clear through the insets above.
Apps with a path of screens (mailboxes, inbox, message) follow the fold with <DuetPanes>: when layout is "narrow" one pane shows, when it is "wide" the focused pane and the one before it show side by side. Going deeper slides the pair along the path instead of opening a modal. Every pane stays mounted (off-screen panes are inert), so drafts and scroll positions survive the fold.
"use client";
import { Duet, DuetPaneBar, DuetPanes, usePanes } from "duet-ui";
const PATH = ["mailboxes", "inbox", "message"];
export function Mail() {
const nav = usePanes(PATH);
return (
<Duet back={nav.back}>
<DuetPanes path={PATH} at={nav.at} expanded={nav.expanded} panes={{
mailboxes: <button onClick={() => nav.go("inbox")}>Inbox</button>,
inbox: <button onClick={() => nav.go("message")}>First message</button>,
message: (
<article>
<DuetPaneBar onClose={nav.close} onToggle={nav.toggle} expanded={nav.expanded} />
<p>Hello from the message pane.</p>
</article>
),
}} />
</Duet>
);
}
<DuetPanes path at expanded? panes>: panes maps each id in path to its content. expanded gives the focused pane the whole window.usePanes(path, initial?) returns at, go(id) (focus a pane, leaving full screen), layout, expanded, toggle() (full screen on and off), close (focus the previous pane, undefined at the root) and back, ready for <Duet back>: it leaves full screen first, then steps back along the path, and is false when that would change nothing on screen.<DuetPaneBar onClose? onToggle? expanded? more?> is the pane's header pill: close, a full-screen toggle (only when layout is "wide") and "…" with the pane's own DuetItems, destructive ones last. It renders nothing when it has no buttons.Try it on the site.
Every iOS and Safari quirk Duet handles, what you would see without the handling, and what is left for you to do. Most need nothing from you. "Without children" means a page that renders <Duet> without children, so the document itself scrolls.
margin-left of the column width and the matching negative margin-right on <html>, marked <html data-duet-reserve>, with both insets 0. Centred content stays centred, nothing is clipped.margin on <html> or reset scrollX, Duet owns both. <Duet reserve={false}> opts out: the page runs under the column and --duet-inset-left / --duet-inset-right hold its width for column(), bleed() or your own padding.scrollY past that range, and the in-document layer moves with the bounce.<html> gets overscroll-behavior-y: none, so the document does not rubber-band, like a page with children. Only the Duo column is affected: other modes and other devices keep the bounce.:where()), so a page's own overscroll-behavior still wins, and with it the controls ride the bounce again.scrollHeight - innerHeight. At the page end Safari's toolbar grows back after the scroll has stopped, sometimes seconds later, and innerHeight steps down (598, 586, 581, 580). Only visualViewport fires resize meanwhile, with no scroll and no window resize until it settles, so a range measured in JavaScript goes stale while the timeline's own range follows the live viewport.max(0px, <scrollHeight>px - 100dvh). 100dvh follows innerHeight frame by frame, so CSS keeps the range live and JavaScript only reports scrollHeight. Measured on the simulator: a computed offset within 0.3pt of scrollY through the regrow (was 18pt), and 0.0pt during smooth scrollIntoView jumps.children to <Duet>: the document then never scrolls, and the controls measure 0.0pt.position: fixed overlay or modal backdropposition: fixed; inset: 0) is open, every Duet control in Safari's column disappears, the close button included, so the user has no way to close the sheet from the column..duet-guard, a small invisible fixed box (pointer-events: none, top z-index) on each viewport edge, 240px tall (since #142), 16px wide on the left and 300px wide reaching inward on the right (since #147), so that hit test lands on it and the column keeps showing the controls. Your scrim stays position: fixed.<div> scrim. <dialog>.showModal() is not covered: its ::backdrop sits in the top layer, above any z-index, so use a fixed <div> scrim instead. For one X, in the column, pass close to <Duet> while the sheet is open and hide the sheet's own X while Duet shows its close (html[data-duet-close] .sheet-x { display: none; }, see Sheet X shown twice). See Modals and sheets in column mode..duet-guard mounted whenever the page is in Safari on a Duo, in every mode, so no frame of a turn, fold or unfold leaves the edge sample point on the scrim. The guard is invisible and takes no taps, so it changes nothing outside the column.backdrop-filter and falls back to the page colour. Measured on the inner screen: blurred scrim, bar rgb(242,242,247) over a dimmed page of rgb(150,150,153); the same scrim without blur, bar rgb(149,149,153). Wide, with the tab bar, Safari never tints its bar, sheet or not. The closed screen's column is not affected.bar check (journey mod03) compares the bar with the page under the scrim.::before (absolute, inset: 0, z-index: -1, pointer-events: none). The blur looks the same and the bar dims with the page. See Modals and sheets in column mode.touch-action on <html> stops at a horizontal scroller, whose overscroll chains to the document.touch-action: pan-y on <html> (and on the inner scroller with children), overscroll-behavior-x: contain on every element at zero specificity, and pins scrollX (0, or the column width for a left column) on every scroll, instantly, so a page's scroll-behavior: smooth can't turn it into a slow creep. Leaving the column resets scrollX to 0. Carousels and code blocks still scroll sideways.overscroll-behavior-x wins over Duet's.pageshow with persisted), the controls start in the page and slide into the column over 2 to 3s, seen in a split left pane and on the closed Duo turned left.scrollX pin was a plain scrollTo, which a page's html { scroll-behavior: smooth } animated and each scroll event restarted.pageshow, re-pins scrollX with behavior: "instant", and turns transitions off for 1.1s after a restore, as at first mount, so the controls land in place. Normal mode changes keep their ~250ms spring.useDuet().hidden is "preview") so the preview's own buttons stay usable.useDuet().preview and hidden if your page lays out differently there.useDuet().preview read false.hidden and confidence are "moving") until the window lands and holds for 150ms (LAND_HOLD). The insets keep their last values, and the column layer is clipped out of the document width, so it adds no width until the landing. After each event it polls every 100ms until the sizes hold for 500ms, for at most 2s.useDuet(), ignore readings with confidence: "moving".resize back while the window animates, yet lays the page out every frame: the page took four sizes over ~220ms with no event. One of them, 585x876 in the 669x951 window, is the closed page's 84pt column handed the open window, which reads as Safari's column.ResizeObserver, which reports every layout, so the window starts settling (controls hidden, confidence: "moving") on the first new size. A column-wide page in the full open window is in transit, never the column: Safari has no column there.:read-write:focus, so inputs, textareas and contenteditable) takes focus, clips the column layer out of the document width and hides it, before the zoom and the keyboard. The keyboard never changes the mode. useDuet().keyboard is true while it is up, and <Duet> sets data-keyboard. In column mode the controls come back when focus leaves.useDuet().keyboard is true, as in column mode; they come back when the keyboard closes. Strip mode (Home Screen apps) is unchanged.visualViewport.height short after a turn (466 against a 580pt page) until the next resize, with no keyboard.:read-write), a select or an iframe has focus, read through shadow roots, and re-reads on focus changes.visualBottom on the context and moves the column band up to end above it, keeping the band's height by taking the lift from the Live Activities allowance first. A Live Activity may then cover the top button. The bar collapsing mid-scroll lags the visual viewport by under 40pt, short of the threshold, so nothing moves then.visualViewport.scale not 1, innerWidth is in zoomed px, so the window signature is unreadable.confidence: "held") until the scale returns to 1. Column mode also requires scale 1, so a page that loads zoomed starts inline.100vh and innerHeight grow and shrink by 18px while the user scrolls, so full-screen sections jump.useDuet().stableHeight, the smallest page height seen at this window width and orientation, and sets it as --duet-vh. In column mode the page runs on under the floating URL bar, and --duet-inset-bottom reserves that space at the end of the scroller.min-height: var(--duet-vh, 100svh).column with the controls in place through the scroll.DUO.island), about 54pt: one button, "…", centred. Nothing rises into the space the activities can take, so no Duet button sits under a native one.useDuet().offsetTop to 40, so every control lands where it does without the bar in screen points. --duet-inset-bottom stays the URL bar alone. Folding or splitting under the bar still reads as in transit.navigation.canGoForward (re-read on currententrychange and popstate) and, in the preview, ends the gap above the raised Back. That gap holds one button with no room left for Live Activities, so with forward history an activity may push the preview's reader/menu button onto Duet's button, never onto Back.viewport-fit=cover applies, and in the sheet about 100ms later still. The sheet's very first frames, the ones a pre-paint script reads, even report the whole 466x678 screen as the page inside the 450x670 sheet, with no column, and the full screen style's first frame (about 200ms) reports the whole screen as both window and page, with no chrome. Until then its window reads as a Safari tab, so the controls are placed at a tab's places, which are under the preview's buttons, and they stay there until the page hydrates.useDuet().hidden is "pending"), from the first paint with bootScript (html[data-duet-pending]). They show as soon as the preview is recognised, or after that second as a tab. A tab's bar collapsing into the same window size on a quick scroll never hides them.bootScript so no frame shows them before the first effect.pvl01 and pvl02 (a load held 1.5s with ?slowjs, sampled from the first paint).useDuet().hidden is "pending") while the window reads as the preview pending its inset, or as the unfolded wide preview with no side band, which every one measured has, and treats the closed screen's full width page under more than a column's bar as still moving (Safari lays one out only for the full screen preview, with no bar). They show in the band as soon as it lands, or after that second where they would be.j38-02 and unf01 (unfold the preview three times, every frame sampled).data-follow). Mid-turn the layer is hidden and the follow is off; the render that shows it again turned the follow back on in a passive effect, which React runs after the browser paints, so that first frame drew the layer unfollowed. Turned on before paint, the follow still missed that frame: a new animation waits for the next frame's animation update.translateY(scrollY) with an inline transform for that frame; the running animation overrides it, and it is dropped two frames later.scr01 (scroll kept across turn, unfold and fold), and every step now checks the layer before paint on each render that shows or moves it.z-index can't help.columnTop and Safari's pill, or opens them wholly over the page when they are too tall.--duet-safe-top-column (0 outside column mode).data-duet-mode and the --duet-* variables are set from an effect, after the first paint.bootScript (duet-ui/boot), which sets them before the first paint in column mode only.<body>. See Pre-paint boot.close passed to <Duet> shows in the column while a Radix or Headless UI sheet is open but does nothing.pointer-events: none on <body> while a modal is open, and the column layer inherits it.pointer-events: auto on .duet, like system chrome over the page.pointerdown, finds the button under the touch's coordinates and clicks it. In a split pane, the tap also tells Duet which side the pane is on.* { animation: none !important } (common under reduced motion) removes the scroll timeline the layer follows the document on, and so does a browser without scroll-timeline support..duet from the reset, or pass the page as children (the steadier choice in any case).viewport-fit=coverstrip mode is never detected.shrink-to-fit=no, and safe-area insets only reach the page with viewport-fit=cover. Frameworks add and rewrite viewport metas after load.shrink-to-fit=no on import and viewport-fit=cover when <Duet> mounts, to every viewport meta, and keeps them on metas added or rewritten later. With no viewport meta it adds width=device-width,initial-scale=1 first.viewport-fit=contain or shrink-to-fit=yes: Duet only appends absent keys and leaves your values as they are.useDuet().keyboard is true). Signals that move while the user scrolls or types (page height, visual viewport) never take a tab out of the column, settled or not; moving into it still happens, so a wrong inline heals.inline removes the overflow, and the signature returns.inline where you expected the column or the strip, or a Duo layout on a device that is not a Duo.Safari/) inline. A Duo whose reported sizes differ (display zoom, a future iOS release) gets inline, and an iPad or another device with matching sizes may read as a Duo.useDuet().duo to switch to a Duo layout, not as proof of the device. Check useDuet().browser to tell why a page is inline. For server-rendered layouts, see Server rendering.html[data-duet-mode="column"], so it only applied in Safari's column on the closed screen, while Duet shows its close button in every mode.data-duet-close on <html> whenever its close button is on screen, in every mode. Inline and column controls hide with the keyboard up, so the attribute drops then and the sheet's X is the one left. A turn's few hidden frames keep it, so the sheet's X does not flash.html[data-duet-close] .sheet-x { display: none; } instead of the column-mode selector.close has no close button at all: Duet shows none and the sheet's own X is hidden.back) was folded into "…", which close mode never draws, while data-duet-close still hid the sheet's X.data-duet-close only when the close is placed. Turned sideways, where Duet hides for the preview, the sheet keeps its own X. Turned back upright with the sheet open, WebKit samples the column's edge against the landscape viewport (mid-edge at y 194 of 388) while laying out fixed boxes in the upright one (600 tall), so the edge guard (see Full-screen position: fixed overlay or modal backdrop) missed the sample point, the scrim filled the column for good and the close was neither painted nor tappable. The guard is now 240px tall, covering both midpoints while staying too small to count as an overlay itself.html[data-duet-close] recipe from Sheet X shown twice.children, opening the burger shows the sidebar over the page instead of sliding the page aside.children, the inner scroller slides by the screen's width in Safari's column and by up to 40% of the page (at most 340pt) inline, where it stays in view as a card to tap back to.children for the sliding card.close takes that one slot instead.inline whenever any column check fails, but it cannot detect a Safari layout that still passes the checks while its buttons moved.pointerdown: they fire on touch down and show no native press state.inline.| App | Shows | Run |
|---|---|---|
| Almanac | Reading app on <DuetPanes>: the list beside the story unfolded, a pane bar, a menu of places, sticky title with bleed | pnpm --filter @duet/example-almanac dev, port 3101 |
| Encore | Music player: badges, an in-page docked mini player, carousels with bleed | pnpm --filter @duet/example-encore dev, port 3103 |
| Meridian | Transit map on Vite: leading, a full-screen map with bleed(false) | pnpm --filter @duet/example-meridian dev, port 3102 |
pnpm dev from the root builds the library and starts the site and every example.
| Path | What |
|---|---|
packages/duet-ui | The library (changelog) |
apps/site | Docs site at getduet.dev. On a desktop it runs itself inside a web Duo you can fold, rotate and scrub through the fold (with ¼ speed), sending the page emulated signals through postMessage |
examples | Example apps |
tools/simulator | Driving the iPhone Duo simulator |
tools/media | Framing simulator recordings for docs |
e2e | Playwright tests |
docs | Releasing, and the dated research notes behind column mode |
pnpm install
pnpm typecheck && pnpm test && pnpm build
pnpm test:e2e
MIT. Not affiliated with Apple.
<dialog>.showModal()::backdropz-index<div>dialog.show()z-index: 2147483647