@sableui/react 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +157 -0
- package/CHANGELOG.md +50 -0
- package/CONTRIBUTING.md +221 -0
- package/DESIGN.md +548 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/THIRD_PARTY_NOTICES.md +44 -0
- package/bin/sable-lint.mjs +0 -0
- package/bin/sable.mjs +1223 -0
- package/dist/_contract-CYScOdf5.d.ts +150 -0
- package/dist/chunk-3IWZYBGH.js +398 -0
- package/dist/chunk-5Z5KLOAU.js +167 -0
- package/dist/chunk-7MG4SHYX.js +15 -0
- package/dist/chunk-ADLLDWLW.js +15 -0
- package/dist/chunk-EP55KSYA.js +14 -0
- package/dist/chunk-J23ELQQZ.js +9396 -0
- package/dist/chunk-JI7WJP2L.js +286 -0
- package/dist/chunk-K7RUHUGC.js +14 -0
- package/dist/chunk-KO3NAEBF.js +15 -0
- package/dist/chunk-L4IEIQQA.js +14 -0
- package/dist/chunk-L6Y46C62.js +270 -0
- package/dist/chunk-NEQNXI2G.js +25 -0
- package/dist/chunk-PAOZBZME.js +40 -0
- package/dist/chunk-RWP4XBSK.js +139 -0
- package/dist/chunk-SRQWQUTW.js +63 -0
- package/dist/chunk-YXBOYWBN.js +15 -0
- package/dist/foundations/index.d.ts +88 -0
- package/dist/foundations/index.js +792 -0
- package/dist/icon/index.d.ts +60 -0
- package/dist/icon/index.js +2 -0
- package/dist/icon-sets/brands.d.ts +44 -0
- package/dist/icon-sets/brands.js +147 -0
- package/dist/icon-sets/reicon.d.ts +8 -0
- package/dist/icon-sets/reicon.js +1 -0
- package/dist/index.d.ts +7013 -0
- package/dist/index.js +15462 -0
- package/dist/ios/chrome.css +83 -0
- package/dist/ios/frame.css +5 -0
- package/dist/ios/index.d.ts +70 -0
- package/dist/ios/index.js +313 -0
- package/dist/ios/preview.d.ts +64 -0
- package/dist/ios/preview.js +133 -0
- package/dist/ios/styles.css +77 -0
- package/dist/layout/index.d.ts +280 -0
- package/dist/layout/index.js +2 -0
- package/dist/meta-6dUzgC6M.d.ts +205 -0
- package/dist/patterns/index.d.ts +36 -0
- package/dist/patterns/index.js +934 -0
- package/dist/presets/index.d.ts +37 -0
- package/dist/presets/index.js +117 -0
- package/dist/registry-CyL28OzJ.d.ts +28 -0
- package/dist/styles.css +1893 -0
- package/dist/theme-provider-BWcizhAt.d.ts +105 -0
- package/dist/themes/atelier/atelier.css +213 -0
- package/dist/themes/atelier/index.d.ts +9 -0
- package/dist/themes/atelier/index.js +1 -0
- package/dist/themes/base/base.css +443 -0
- package/dist/themes/base/index.d.ts +9 -0
- package/dist/themes/base/index.js +1 -0
- package/dist/themes/ember/ember.css +270 -0
- package/dist/themes/ember/index.d.ts +9 -0
- package/dist/themes/ember/index.js +1 -0
- package/dist/themes/index.d.ts +10 -0
- package/dist/themes/index.js +8 -0
- package/dist/themes/meridian/index.d.ts +9 -0
- package/dist/themes/meridian/index.js +1 -0
- package/dist/themes/meridian/meridian.css +255 -0
- package/dist/themes/poppy/index.d.ts +6 -0
- package/dist/themes/poppy/index.js +1 -0
- package/dist/themes/poppy/poppy.css +174 -0
- package/dist/themes/prism/index.d.ts +6 -0
- package/dist/themes/prism/index.js +1 -0
- package/dist/themes/prism/prism.css +183 -0
- package/dist/themes/verdant/index.d.ts +9 -0
- package/dist/themes/verdant/index.js +1 -0
- package/dist/themes/verdant/verdant.css +210 -0
- package/dist/themes.css +30 -0
- package/llms/components/accordion.md +93 -0
- package/llms/components/app-header.md +70 -0
- package/llms/components/app-shell.md +117 -0
- package/llms/components/attachment.md +105 -0
- package/llms/components/avatar.md +141 -0
- package/llms/components/badge.md +117 -0
- package/llms/components/banner.md +119 -0
- package/llms/components/bottom-nav.md +80 -0
- package/llms/components/breadcrumbs.md +75 -0
- package/llms/components/browser.md +67 -0
- package/llms/components/bubble.md +119 -0
- package/llms/components/button.md +199 -0
- package/llms/components/calendar.md +75 -0
- package/llms/components/card.md +135 -0
- package/llms/components/carousel.md +87 -0
- package/llms/components/chat-composer.md +126 -0
- package/llms/components/checkbox-card.md +98 -0
- package/llms/components/checkbox.md +90 -0
- package/llms/components/code-block.md +81 -0
- package/llms/components/code.md +62 -0
- package/llms/components/collapsible.md +83 -0
- package/llms/components/color-field.md +100 -0
- package/llms/components/combobox.md +119 -0
- package/llms/components/command-menu.md +158 -0
- package/llms/components/context-card.md +73 -0
- package/llms/components/context-menu.md +79 -0
- package/llms/components/data-table.md +99 -0
- package/llms/components/date-picker.md +97 -0
- package/llms/components/description.md +96 -0
- package/llms/components/destructive-action-modal.md +76 -0
- package/llms/components/dialog.md +127 -0
- package/llms/components/dots-menu.md +64 -0
- package/llms/components/empty-state.md +113 -0
- package/llms/components/entity.md +67 -0
- package/llms/components/feedback.md +63 -0
- package/llms/components/fieldset.md +106 -0
- package/llms/components/file-dropzone.md +103 -0
- package/llms/components/form.md +111 -0
- package/llms/components/gliding-highlight.md +84 -0
- package/llms/components/inline-status.md +62 -0
- package/llms/components/input.md +127 -0
- package/llms/components/kbd.md +57 -0
- package/llms/components/label.md +73 -0
- package/llms/components/marker.md +99 -0
- package/llms/components/marquee.md +66 -0
- package/llms/components/menu.md +122 -0
- package/llms/components/message-scroller.md +110 -0
- package/llms/components/message.md +114 -0
- package/llms/components/meter.md +124 -0
- package/llms/components/middle-truncate.md +62 -0
- package/llms/components/note.md +81 -0
- package/llms/components/notification-badge.md +67 -0
- package/llms/components/number-field.md +70 -0
- package/llms/components/otp-field.md +101 -0
- package/llms/components/page-header.md +76 -0
- package/llms/components/page-loader.md +67 -0
- package/llms/components/pagination.md +92 -0
- package/llms/components/panel-group.md +120 -0
- package/llms/components/panel-section.md +122 -0
- package/llms/components/phone.md +70 -0
- package/llms/components/pill.md +73 -0
- package/llms/components/popover.md +79 -0
- package/llms/components/progress.md +98 -0
- package/llms/components/radio-card.md +90 -0
- package/llms/components/radio.md +86 -0
- package/llms/components/relative-time.md +60 -0
- package/llms/components/scroller.md +88 -0
- package/llms/components/scrub-field.md +97 -0
- package/llms/components/search-input.md +84 -0
- package/llms/components/select.md +106 -0
- package/llms/components/separator.md +70 -0
- package/llms/components/settings-menu.md +139 -0
- package/llms/components/sheet.md +97 -0
- package/llms/components/show-more.md +59 -0
- package/llms/components/side-nav.md +106 -0
- package/llms/components/sidebar.md +87 -0
- package/llms/components/skeleton.md +62 -0
- package/llms/components/slider.md +114 -0
- package/llms/components/spinner.md +87 -0
- package/llms/components/status-bar.md +91 -0
- package/llms/components/status-dot.md +70 -0
- package/llms/components/stepper.md +125 -0
- package/llms/components/streaming-text.md +86 -0
- package/llms/components/switch.md +87 -0
- package/llms/components/table.md +112 -0
- package/llms/components/tabs.md +84 -0
- package/llms/components/textarea.md +88 -0
- package/llms/components/theme-selector.md +95 -0
- package/llms/components/theme-switcher.md +71 -0
- package/llms/components/thinking-orb.md +91 -0
- package/llms/components/toast.md +130 -0
- package/llms/components/toggle.md +95 -0
- package/llms/components/toolbar.md +168 -0
- package/llms/components/tooltip.md +93 -0
- package/llms/components/tree.md +227 -0
- package/llms/components/video.md +66 -0
- package/llms/components/workspace.md +138 -0
- package/llms/foundations/accessibility.md +60 -0
- package/llms/foundations/motion.md +96 -0
- package/llms/foundations/scrolling.md +41 -0
- package/llms/foundations/shape.md +73 -0
- package/llms/foundations/spacing.md +82 -0
- package/llms/foundations/surfaces.md +69 -0
- package/llms/foundations/typography.md +120 -0
- package/llms/layout/aspect-box.md +37 -0
- package/llms/layout/bleed.md +31 -0
- package/llms/layout/center.md +37 -0
- package/llms/layout/columns.md +51 -0
- package/llms/layout/grid-overlay.md +40 -0
- package/llms/layout/grid.md +47 -0
- package/llms/layout/page.md +39 -0
- package/llms/layout/region.md +49 -0
- package/llms/layout/row.md +40 -0
- package/llms/layout/safe-area.md +37 -0
- package/llms/layout/spacer.md +39 -0
- package/llms/layout/split-pane.md +41 -0
- package/llms/layout/stack.md +39 -0
- package/llms/patterns/auth-page.md +68 -0
- package/llms/patterns/brand-landing-page.md +423 -0
- package/llms/patterns/chat-panel.md +210 -0
- package/llms/patterns/dashboard-page.md +170 -0
- package/llms/patterns/feed-page.md +103 -0
- package/llms/patterns/landing-page.md +122 -0
- package/llms/patterns/list-detail-page.md +100 -0
- package/llms/patterns/settings-page.md +121 -0
- package/llms/reference/token-intent.md +324 -0
- package/llms/reference/utilities.md +129 -0
- package/llms/tasks/adding-a-component.md +113 -0
- package/llms/tasks/auditing-an-app.md +216 -0
- package/llms/tasks/building-a-page.md +109 -0
- package/llms/tasks/building-a-tool.md +141 -0
- package/llms/tasks/building-an-ios-prototype.md +105 -0
- package/llms/tasks/building-with-sable.md +73 -0
- package/llms/tasks/choosing-a-component.md +109 -0
- package/llms/tasks/converting-an-app.md +139 -0
- package/llms/tasks/creating-a-preset.md +43 -0
- package/llms/tasks/creating-design-assets.md +96 -0
- package/llms/tasks/designing-a-surface.md +115 -0
- package/llms/tasks/developing-taste.md +157 -0
- package/llms/tasks/getting-started.md +63 -0
- package/llms/tasks/learning-from-builds.md +74 -0
- package/llms/tasks/reviewing-a-design.md +114 -0
- package/llms/tasks/reviewing-a-journey.md +84 -0
- package/llms/tasks/reviewing-motion.md +92 -0
- package/llms/tasks/using-presets.md +87 -0
- package/llms/taste/asset-plan.template.json +53 -0
- package/llms/taste/composition-follows-content.md +20 -0
- package/llms/taste/hierarchy-is-a-relationship.md +22 -0
- package/llms/taste/lessons.json +57 -0
- package/llms/taste/motion-needs-a-reason.md +21 -0
- package/llms/taste/surface-states-travel-together.md +20 -0
- package/llms-full.txt +14906 -0
- package/llms.txt +405 -0
- package/package.json +185 -3
- package/registry.json +18812 -0
- package/scripts/agent-setup.mjs +54 -0
- package/scripts/design-atlas.mjs +221 -0
- package/scripts/design-directions.mjs +123 -0
- package/scripts/design-memory.mjs +166 -0
- package/scripts/design-preferences.mjs +227 -0
- package/scripts/design-presets.mjs +48 -0
- package/scripts/grade.mjs +509 -0
- package/scripts/lint-rules.mjs +1065 -0
- package/scripts/review-evidence.mjs +120 -0
- package/scripts/skeleton.mjs +248 -0
- package/skills/entry/SKILL.md +34 -0
- package/skills/sable/SKILL.md +187 -0
- package/skills/sable/agents/openai.yaml +6 -0
- package/targets/ios.json +982 -0
- package/third-party/reicon-LICENSE.txt +20 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/* Optional shared browser iOS material; imported only in the native document. */
|
|
2
|
+
/* The scroll-edge effect under the status bar and navigation: a fade from the
|
|
3
|
+
page color, not a backdrop blur. A blur darkens toward the edges of whatever
|
|
4
|
+
clips it, which drew grey smudges in the screen's top corners when framed. */
|
|
5
|
+
.ios-route::before {
|
|
6
|
+
content: ''; position: absolute; inset: 0 0 auto; height: var(--ios-header-height); z-index: 10; pointer-events: none;
|
|
7
|
+
background: linear-gradient(to bottom, var(--ios-canvas) 45%, color-mix(in srgb, var(--ios-canvas) 85%, transparent) 65%, color-mix(in srgb, var(--ios-canvas) 40%, transparent) 85%, transparent);
|
|
8
|
+
}
|
|
9
|
+
.ios-prototype .k-navbar { top: var(--ios-safe-top); margin-top: var(--ios-navbar-offset, var(--ios-safe-top)); color: var(--ios-ink); background: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
10
|
+
/* A single full-height scrim owns the header, avoiding the upstream gradient restart. */
|
|
11
|
+
.ios-prototype .k-navbar > .pointer-events-none { display: none; }
|
|
12
|
+
.ios-navigation-title, .ios-prototype .ios-sheet-heading h2 { font-family: var(--ios-display, inherit); font-weight: var(--ios-title-weight, 600); }
|
|
13
|
+
.ios-glass-filters { position: absolute; pointer-events: none; }
|
|
14
|
+
.ios-prototype .k-glass {
|
|
15
|
+
position: relative;
|
|
16
|
+
background: color-mix(in srgb, var(--ios-canvas) calc(var(--ios-glass-tint) * 100%), transparent);
|
|
17
|
+
-webkit-backdrop-filter: blur(7px) saturate(1.25);
|
|
18
|
+
backdrop-filter: blur(7px) saturate(1.25);
|
|
19
|
+
--tw-shadow:
|
|
20
|
+
inset 0 .75px .5px rgb(255 255 255 / .75),
|
|
21
|
+
inset 0 -.75px .5px rgb(255 255 255 / .5),
|
|
22
|
+
inset .75px 0 .5px rgb(255 255 255 / .35),
|
|
23
|
+
inset 0 -2px 6px color-mix(in srgb, var(--ios-ink) 4%, transparent),
|
|
24
|
+
0 3px 12px color-mix(in srgb, var(--ios-ink) 7%, transparent),
|
|
25
|
+
0 .5px 2px color-mix(in srgb, var(--ios-ink) 3%, transparent);
|
|
26
|
+
}
|
|
27
|
+
/* Chromium can refract the actual backdrop; other engines retain the CSS material. */
|
|
28
|
+
@supports (backdrop-filter: url('#glass')) {
|
|
29
|
+
.ios-prototype .k-glass {
|
|
30
|
+
backdrop-filter: var(--ios-glass-optics, blur(7px)) saturate(1.25);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
.ios-prototype.dark .k-glass {
|
|
34
|
+
--tw-shadow:
|
|
35
|
+
inset 0 .75px .5px rgb(255 255 255 / .4),
|
|
36
|
+
inset 0 -.75px .5px rgb(255 255 255 / .2),
|
|
37
|
+
inset .75px 0 .5px rgb(255 255 255 / .15),
|
|
38
|
+
0 3px 12px rgb(0 0 0 / .14);
|
|
39
|
+
}
|
|
40
|
+
/* Directional rim reflections and a faint surface reflection, separate from the fill. */
|
|
41
|
+
.ios-prototype .k-glass::before,
|
|
42
|
+
.ios-prototype .k-glass::after {
|
|
43
|
+
content: ''; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
|
|
44
|
+
}
|
|
45
|
+
.ios-prototype .k-glass::before {
|
|
46
|
+
padding: 1px;
|
|
47
|
+
background: linear-gradient(145deg, rgb(255 255 255 / .95), rgb(255 255 255 / .1) 32%, rgb(255 255 255 / .4) 60%, rgb(255 255 255 / .8));
|
|
48
|
+
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
49
|
+
-webkit-mask-composite: xor;
|
|
50
|
+
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
51
|
+
mask-composite: exclude;
|
|
52
|
+
}
|
|
53
|
+
.ios-prototype .k-glass::after {
|
|
54
|
+
background: linear-gradient(165deg, rgb(255 255 255 / .17), transparent 42%, transparent 70%, rgb(255 255 255 / .06));
|
|
55
|
+
}
|
|
56
|
+
.ios-prototype.dark .k-glass::before { opacity: .55; }
|
|
57
|
+
.ios-prototype.dark .k-glass::after { opacity: .65; }
|
|
58
|
+
.ios-prototype :focus-visible { outline-color: var(--color-primary); }
|
|
59
|
+
.native-tabbar { position: absolute; z-index: 30; bottom: var(--ios-tabbar-bottom); }
|
|
60
|
+
/* Carry the toolbar's background through the home safe area, not just to the glass bar. */
|
|
61
|
+
.native-tabbar > .pointer-events-none {
|
|
62
|
+
bottom: calc(-1 * var(--ios-tabbar-bottom));
|
|
63
|
+
height: calc(var(--k-safe-area-bottom) + 96px + var(--ios-tabbar-bottom));
|
|
64
|
+
}
|
|
65
|
+
/* The home-area fade under the tab bar is unblurred for the same reason as the
|
|
66
|
+
header; the glass bar above it keeps its material. */
|
|
67
|
+
.native-tabbar > .pointer-events-none { -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
68
|
+
.native-tab-scrim { background: linear-gradient(to top, color-mix(in srgb, var(--ios-canvas) 92%, transparent) 25%, color-mix(in srgb, var(--ios-canvas) 60%, transparent) 60%, transparent); }
|
|
69
|
+
.native-tab-glass { display: flex; width: 100%; border-radius: 36px; }
|
|
70
|
+
.native-tab-glass > button { flex: 1; position: relative; z-index: 1; }
|
|
71
|
+
.native-tab-glass > button:not([aria-current='page']) { color: var(--ios-ink); }
|
|
72
|
+
.ios-prototype .ios-icon-action { width: 44px; height: 44px; min-width: 44px; padding: 0; flex-shrink: 0; }
|
|
73
|
+
.ios-icon-action svg { flex-shrink: 0; }
|
|
74
|
+
.native-tab-glass > button[aria-current='page']::before {
|
|
75
|
+
content: ''; position: absolute; inset: 5px 6px; z-index: -1; border-radius: 28px; pointer-events: none;
|
|
76
|
+
background: color-mix(in srgb, var(--ios-ink) 6%, transparent);
|
|
77
|
+
box-shadow: inset 0 .5px 0 rgb(255 255 255 / .3);
|
|
78
|
+
}
|
|
79
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
80
|
+
.ios-prototype .k-glass { -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
81
|
+
.native-tabbar > .pointer-events-none { -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
82
|
+
.ios-prototype .k-glass::after { display: none; }
|
|
83
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/* Host-page layout for PhoneFrame only. The device itself is Sable's Phone, so it
|
|
2
|
+
follows the host's theme; nothing here reaches the native document. */
|
|
3
|
+
.ios-preview-host { width: 100%; min-width: 0; margin-inline: auto; position: relative; }
|
|
4
|
+
.ios-phone-frame { position: absolute; inset-inline-start: 0; top: 0; transform-origin: top left; }
|
|
5
|
+
.ios-phone-screen { display: block; width: 100%; height: 100%; border: 0; }
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { CSSProperties, ReactNode, ComponentProps } from 'react';
|
|
3
|
+
import { NavbarBackLink, Button, Navbar, Page, TabbarLink, Tabbar } from 'konsta/react';
|
|
4
|
+
|
|
5
|
+
declare const NAMES: readonly ["--background", "--card", "--foreground", "--muted-foreground", "--primary", "--primary-foreground", "--muted", "--font-sans", "--font-display", "--type-title-weight"];
|
|
6
|
+
/** Resolved preset values or app-owned CSS variables, keyed by Sable's semantic names. */
|
|
7
|
+
type IosBrandTokens = Partial<Record<(typeof NAMES)[number], string>>;
|
|
8
|
+
/**
|
|
9
|
+
* Set Sable's semantic tokens on the native root. `ios/styles.css` maps those
|
|
10
|
+
* names onto the native chrome (canvas, ink, panels, tint, title font), so this
|
|
11
|
+
* is only needed when the values do not already come from Sable's styles in the
|
|
12
|
+
* same document — e.g. a preset sent from a host page. Geometry is never themed.
|
|
13
|
+
*/
|
|
14
|
+
declare function iosThemeStyle(tokens: IosBrandTokens): CSSProperties;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The root of a native prototype document: Konsta's iOS App, Sable's preset on
|
|
18
|
+
* the chrome, glass material, and the system status bar and home indicator.
|
|
19
|
+
*
|
|
20
|
+
* Load Sable's styles in the same document and set `data-sable-theme` on <html>;
|
|
21
|
+
* the chrome and any Sable content then follow the preset together. `tokens`
|
|
22
|
+
* overrides individual semantic values. `mode` also sets `dark` on <html>, so
|
|
23
|
+
* Sable popups portaled to <body> follow the prototype's appearance.
|
|
24
|
+
*/
|
|
25
|
+
declare function NativeApp({ children, mode, glassTint, reducedMotion, className, tokens, style, }: {
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
mode?: "light" | "dark";
|
|
28
|
+
glassTint?: number;
|
|
29
|
+
reducedMotion?: boolean;
|
|
30
|
+
className?: string;
|
|
31
|
+
tokens?: IosBrandTokens;
|
|
32
|
+
style?: CSSProperties;
|
|
33
|
+
}): react.JSX.Element;
|
|
34
|
+
|
|
35
|
+
type Navigation = {
|
|
36
|
+
push: (id: string, content: ReactNode) => void;
|
|
37
|
+
pop: () => void;
|
|
38
|
+
depth: number;
|
|
39
|
+
};
|
|
40
|
+
declare function useNavigation(): Navigation;
|
|
41
|
+
/** Keep one mounted stack per tab so each tab retains its route and scroll. */
|
|
42
|
+
declare function NavigationStack({ children, active, label }: {
|
|
43
|
+
children: ReactNode;
|
|
44
|
+
active?: boolean;
|
|
45
|
+
label: string;
|
|
46
|
+
}): react.JSX.Element;
|
|
47
|
+
|
|
48
|
+
/** Browser dialog owns focus trapping, Escape, inert background, and restoration. */
|
|
49
|
+
declare function IosSheet({ open, onOpenChange, title, children, detent }: {
|
|
50
|
+
open: boolean;
|
|
51
|
+
onOpenChange: (open: boolean) => void;
|
|
52
|
+
title: string;
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
detent?: 'medium' | 'large';
|
|
55
|
+
}): react.JSX.Element;
|
|
56
|
+
|
|
57
|
+
/** A screen: the scrolling page under the navigation bar. Its content is Sable. */
|
|
58
|
+
declare function IosPage(props: ComponentProps<typeof Page>): react.JSX.Element;
|
|
59
|
+
/** The navigation bar's back control. */
|
|
60
|
+
declare function IosBackLink({ component, ...props }: ComponentProps<typeof NavbarBackLink>): react.JSX.Element;
|
|
61
|
+
/** One tab bar destination. Pass a Sable `<Icon>` as `icon`. */
|
|
62
|
+
declare function IosTab({ component, active, ...props }: ComponentProps<typeof TabbarLink>): react.JSX.Element;
|
|
63
|
+
/** Brand typography on native navigation, including its expanding large title. */
|
|
64
|
+
declare function IosNavbar({ titleClassName, titleLargeFontSizeIos, ...props }: ComponentProps<typeof Navbar>): react.JSX.Element;
|
|
65
|
+
/** The tab bar: shared glass material and home-area fade; children are IosTabs. */
|
|
66
|
+
declare function IosTabbar({ children, className, colors, ...props }: ComponentProps<typeof Tabbar>): react.JSX.Element;
|
|
67
|
+
/** Keep icon actions square inside the native circular glass container. */
|
|
68
|
+
declare function IosIconButton({ className, ...props }: ComponentProps<typeof Button>): react.JSX.Element;
|
|
69
|
+
|
|
70
|
+
export { IosBackLink, type IosBrandTokens, IosIconButton, IosNavbar, IosPage, IosSheet, IosTab, IosTabbar, NativeApp, NavigationStack, iosThemeStyle, useNavigation };
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { StatusIcons } from '../chunk-NEQNXI2G.js';
|
|
2
|
+
import { App, Sheet, Button, Page, NavbarBackLink, TabbarLink, Navbar, Tabbar, Glass } from 'konsta/react';
|
|
3
|
+
import { createContext, useEffect, useContext, useState, useRef, useId } from 'react';
|
|
4
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
5
|
+
|
|
6
|
+
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
7
|
+
function refractionMap(width, height) {
|
|
8
|
+
const canvas = document.createElement("canvas");
|
|
9
|
+
canvas.width = Math.ceil(width);
|
|
10
|
+
canvas.height = Math.ceil(height);
|
|
11
|
+
const context = canvas.getContext("2d");
|
|
12
|
+
const pixels = context.createImageData(canvas.width, canvas.height);
|
|
13
|
+
const radius = Math.min(width, height) / 2;
|
|
14
|
+
const edge = Math.min(12, radius * 0.45);
|
|
15
|
+
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) {
|
|
16
|
+
const dx = x + 0.5 - width / 2, dy = y + 0.5 - height / 2;
|
|
17
|
+
const qx = Math.abs(dx) - width / 2 + radius;
|
|
18
|
+
const qy = Math.abs(dy) - height / 2 + radius;
|
|
19
|
+
const ox = Math.max(qx, 0), oy = Math.max(qy, 0);
|
|
20
|
+
const length = Math.hypot(ox, oy);
|
|
21
|
+
const depth = radius - length - Math.min(Math.max(qx, qy), 0);
|
|
22
|
+
const bend = depth > 0 && depth < edge ? Math.sin(depth / edge * Math.PI) * 0.44 : 0;
|
|
23
|
+
const nx = length ? Math.sign(dx) * ox / length : 0;
|
|
24
|
+
const ny = length ? Math.sign(dy) * oy / length : 0;
|
|
25
|
+
const index = (y * canvas.width + x) * 4;
|
|
26
|
+
pixels.data[index] = Math.round((0.5 + nx * bend) * 255);
|
|
27
|
+
pixels.data[index + 1] = Math.round((0.5 + ny * bend) * 255);
|
|
28
|
+
pixels.data[index + 2] = 128;
|
|
29
|
+
pixels.data[index + 3] = 255;
|
|
30
|
+
}
|
|
31
|
+
context.putImageData(pixels, 0, 0);
|
|
32
|
+
return canvas.toDataURL();
|
|
33
|
+
}
|
|
34
|
+
function LiquidGlassMaterial() {
|
|
35
|
+
const svg = useRef(null);
|
|
36
|
+
const prefix = useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const root = svg.current?.closest(".ios-prototype");
|
|
39
|
+
const defs = svg.current?.querySelector("defs");
|
|
40
|
+
if (!root || !defs) return;
|
|
41
|
+
const filters = /* @__PURE__ */ new Map();
|
|
42
|
+
let sequence = 0, frame = 0;
|
|
43
|
+
const pending = /* @__PURE__ */ new Set();
|
|
44
|
+
const primitive = (name, attributes) => {
|
|
45
|
+
const element = document.createElementNS(SVG_NS, name);
|
|
46
|
+
for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, value);
|
|
47
|
+
return element;
|
|
48
|
+
};
|
|
49
|
+
const update = (element) => {
|
|
50
|
+
const { width, height } = element.getBoundingClientRect();
|
|
51
|
+
if (!width || !height) return;
|
|
52
|
+
let filter = filters.get(element);
|
|
53
|
+
if (!filter) {
|
|
54
|
+
filter = primitive("filter", { id: `${prefix}-glass-${sequence++}`, x: "0", y: "0", width: "100%", height: "100%", "color-interpolation-filters": "sRGB" });
|
|
55
|
+
defs.append(filter);
|
|
56
|
+
filters.set(element, filter);
|
|
57
|
+
}
|
|
58
|
+
filter.replaceChildren(
|
|
59
|
+
primitive("feGaussianBlur", { in: "SourceGraphic", stdDeviation: "3", result: "softened" }),
|
|
60
|
+
primitive("feImage", { href: refractionMap(width, height), x: "0", y: "0", width: String(width), height: String(height), preserveAspectRatio: "none", result: "rim" }),
|
|
61
|
+
primitive("feDisplacementMap", { in: "softened", in2: "rim", scale: "14", xChannelSelector: "R", yChannelSelector: "G" })
|
|
62
|
+
);
|
|
63
|
+
element.style.setProperty("--ios-glass-optics", `url("#${filter.id}")`);
|
|
64
|
+
};
|
|
65
|
+
const resize = new ResizeObserver((entries) => {
|
|
66
|
+
for (const entry of entries) pending.add(entry.target);
|
|
67
|
+
cancelAnimationFrame(frame);
|
|
68
|
+
frame = requestAnimationFrame(() => {
|
|
69
|
+
for (const element of pending) update(element);
|
|
70
|
+
pending.clear();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
const scan = () => {
|
|
74
|
+
for (const element of root.querySelectorAll(".k-glass")) {
|
|
75
|
+
if (!filters.has(element)) {
|
|
76
|
+
resize.observe(element);
|
|
77
|
+
update(element);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const [element, filter] of filters) if (!root.contains(element)) {
|
|
81
|
+
resize.unobserve(element);
|
|
82
|
+
filter.remove();
|
|
83
|
+
filters.delete(element);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
scan();
|
|
87
|
+
const mutations = new MutationObserver((records) => {
|
|
88
|
+
if (records.some((record) => [...record.addedNodes, ...record.removedNodes].some((node) => node instanceof Element && (node.matches(".k-glass") || node.querySelector(".k-glass"))))) scan();
|
|
89
|
+
});
|
|
90
|
+
mutations.observe(root, { childList: true, subtree: true });
|
|
91
|
+
return () => {
|
|
92
|
+
cancelAnimationFrame(frame);
|
|
93
|
+
resize.disconnect();
|
|
94
|
+
mutations.disconnect();
|
|
95
|
+
for (const [element, filter] of filters) {
|
|
96
|
+
element.style.removeProperty("--ios-glass-optics");
|
|
97
|
+
filter.remove();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}, [prefix]);
|
|
101
|
+
return /* @__PURE__ */ jsx("svg", { ref: svg, className: "ios-glass-filters", width: "0", height: "0", "aria-hidden": "true", focusable: "false", children: /* @__PURE__ */ jsx("defs", {}) });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/ios/theme.ts
|
|
105
|
+
var NAMES = [
|
|
106
|
+
"--background",
|
|
107
|
+
"--card",
|
|
108
|
+
"--foreground",
|
|
109
|
+
"--muted-foreground",
|
|
110
|
+
"--primary",
|
|
111
|
+
"--primary-foreground",
|
|
112
|
+
"--muted",
|
|
113
|
+
"--font-sans",
|
|
114
|
+
"--font-display",
|
|
115
|
+
"--type-title-weight"
|
|
116
|
+
];
|
|
117
|
+
function iosThemeStyle(tokens) {
|
|
118
|
+
return Object.fromEntries(NAMES.flatMap((name) => tokens[name] === void 0 ? [] : [[name, tokens[name]]]));
|
|
119
|
+
}
|
|
120
|
+
function NativeApp({
|
|
121
|
+
children,
|
|
122
|
+
mode = "light",
|
|
123
|
+
glassTint = 0.35,
|
|
124
|
+
reducedMotion = false,
|
|
125
|
+
className = "",
|
|
126
|
+
tokens = {},
|
|
127
|
+
style
|
|
128
|
+
}) {
|
|
129
|
+
const dark = mode === "dark";
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
document.documentElement.classList.toggle("dark", dark);
|
|
132
|
+
}, [dark]);
|
|
133
|
+
const tint = Math.max(0, Math.min(1, Number.isFinite(glassTint) ? glassTint : 0.35));
|
|
134
|
+
return /* @__PURE__ */ jsxs(
|
|
135
|
+
App,
|
|
136
|
+
{
|
|
137
|
+
theme: "ios",
|
|
138
|
+
dark,
|
|
139
|
+
className: `ios-prototype ${dark ? "dark" : ""} ${className}`,
|
|
140
|
+
"data-reduced-motion": reducedMotion || void 0,
|
|
141
|
+
style: { ...iosThemeStyle(tokens), "--ios-glass-tint": tint, ...style },
|
|
142
|
+
children: [
|
|
143
|
+
/* @__PURE__ */ jsx(LiquidGlassMaterial, {}),
|
|
144
|
+
/* @__PURE__ */ jsxs("div", { className: "ios-status-bar", "aria-hidden": "true", children: [
|
|
145
|
+
/* @__PURE__ */ jsx("span", { children: "9:41" }),
|
|
146
|
+
/* @__PURE__ */ jsx("span", { className: "ios-island" }),
|
|
147
|
+
/* @__PURE__ */ jsx(StatusIcons, { className: "ios-status-icons" })
|
|
148
|
+
] }),
|
|
149
|
+
children,
|
|
150
|
+
/* @__PURE__ */ jsx("div", { className: "ios-home-indicator", "aria-hidden": "true" })
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
var Context = createContext(null);
|
|
156
|
+
function useNavigation() {
|
|
157
|
+
const value = useContext(Context);
|
|
158
|
+
if (!value) throw new Error("useNavigation needs a NavigationStack");
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
function NavigationStack({ children, active = true, label }) {
|
|
162
|
+
const [entries, setEntries] = useState([]);
|
|
163
|
+
const [direction, setDirection] = useState("push");
|
|
164
|
+
const gesture = useRef(null);
|
|
165
|
+
const host = useRef(null);
|
|
166
|
+
const focusTarget = useRef(null);
|
|
167
|
+
const push = (id, content) => {
|
|
168
|
+
const returnFocus = document.activeElement;
|
|
169
|
+
setDirection("push");
|
|
170
|
+
setEntries((previous) => [...previous, { id, content, returnFocus }]);
|
|
171
|
+
};
|
|
172
|
+
const pop = () => {
|
|
173
|
+
setDirection("pop");
|
|
174
|
+
setEntries((previous) => {
|
|
175
|
+
focusTarget.current = previous.at(-1)?.returnFocus || null;
|
|
176
|
+
return previous.slice(0, -1);
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (!active) return;
|
|
181
|
+
if (direction === "pop" && focusTarget.current?.isConnected) focusTarget.current.focus({ preventScroll: true });
|
|
182
|
+
else if (entries.length) host.current?.querySelector("[data-ios-route]:last-child h1, [data-ios-route]:last-child [data-ios-title]")?.focus({ preventScroll: true });
|
|
183
|
+
}, [entries.length, active, direction]);
|
|
184
|
+
const endGesture = (event) => {
|
|
185
|
+
const start = gesture.current;
|
|
186
|
+
gesture.current = null;
|
|
187
|
+
if (start && entries.length && event.clientX - start.x > 80 && Math.abs(event.clientY - start.y) < 50) pop();
|
|
188
|
+
};
|
|
189
|
+
return /* @__PURE__ */ jsx(Context.Provider, { value: { push, pop, depth: entries.length }, children: /* @__PURE__ */ jsxs(
|
|
190
|
+
"div",
|
|
191
|
+
{
|
|
192
|
+
ref: host,
|
|
193
|
+
className: "ios-navigation-stack",
|
|
194
|
+
hidden: !active,
|
|
195
|
+
"aria-label": label,
|
|
196
|
+
"data-direction": direction,
|
|
197
|
+
onPointerDown: (event) => {
|
|
198
|
+
if (event.clientX < 24 && event.isPrimary) gesture.current = { x: event.clientX, y: event.clientY };
|
|
199
|
+
},
|
|
200
|
+
onPointerUp: endGesture,
|
|
201
|
+
onPointerCancel: () => {
|
|
202
|
+
gesture.current = null;
|
|
203
|
+
},
|
|
204
|
+
children: [
|
|
205
|
+
/* @__PURE__ */ jsx("div", { className: "ios-route", "data-ios-route": "root", hidden: entries.length > 0, children }),
|
|
206
|
+
entries.map((entry, index) => /* @__PURE__ */ jsx("div", { className: "ios-route", "data-ios-route": entry.id, hidden: index !== entries.length - 1, children: entry.content }, `${index}:${entry.id}`))
|
|
207
|
+
]
|
|
208
|
+
}
|
|
209
|
+
) });
|
|
210
|
+
}
|
|
211
|
+
function IosSheet({ open, onOpenChange, title, children, detent = "medium" }) {
|
|
212
|
+
const dialog = useRef(null);
|
|
213
|
+
const titleId = useId();
|
|
214
|
+
const [shown, setShown] = useState(false);
|
|
215
|
+
const [expanded, setExpanded] = useState(detent === "large");
|
|
216
|
+
const startY = useRef(null);
|
|
217
|
+
const dragged = useRef(false);
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const el = dialog.current;
|
|
220
|
+
if (!el) return;
|
|
221
|
+
if (open) {
|
|
222
|
+
setExpanded(detent === "large");
|
|
223
|
+
if (!el.open) {
|
|
224
|
+
el.showModal();
|
|
225
|
+
el.querySelector("h2")?.focus();
|
|
226
|
+
}
|
|
227
|
+
const frame = requestAnimationFrame(() => setShown(true));
|
|
228
|
+
return () => cancelAnimationFrame(frame);
|
|
229
|
+
}
|
|
230
|
+
setShown(false);
|
|
231
|
+
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches || !!el.closest("[data-reduced-motion]");
|
|
232
|
+
const timer = setTimeout(() => el.close(), reduced ? 0 : 220);
|
|
233
|
+
return () => clearTimeout(timer);
|
|
234
|
+
}, [open, detent]);
|
|
235
|
+
return /* @__PURE__ */ jsx(
|
|
236
|
+
"dialog",
|
|
237
|
+
{
|
|
238
|
+
ref: dialog,
|
|
239
|
+
className: "ios-sheet-dialog",
|
|
240
|
+
"aria-labelledby": titleId,
|
|
241
|
+
onCancel: (event) => {
|
|
242
|
+
event.preventDefault();
|
|
243
|
+
onOpenChange(false);
|
|
244
|
+
},
|
|
245
|
+
onClick: (event) => {
|
|
246
|
+
if (event.target === event.currentTarget) onOpenChange(false);
|
|
247
|
+
},
|
|
248
|
+
children: /* @__PURE__ */ jsxs(Sheet, { opened: shown, backdrop: false, className: "ios-sheet", style: { height: expanded ? "90%" : "56%" }, children: [
|
|
249
|
+
/* @__PURE__ */ jsx(
|
|
250
|
+
Button,
|
|
251
|
+
{
|
|
252
|
+
clear: true,
|
|
253
|
+
className: "ios-sheet-grabber",
|
|
254
|
+
"aria-label": expanded ? "Reduce sheet height" : "Expand sheet",
|
|
255
|
+
onClick: () => {
|
|
256
|
+
if (dragged.current) {
|
|
257
|
+
dragged.current = false;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
setExpanded((value) => !value);
|
|
261
|
+
},
|
|
262
|
+
onPointerDown: (event) => {
|
|
263
|
+
startY.current = event.clientY;
|
|
264
|
+
dragged.current = false;
|
|
265
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
266
|
+
},
|
|
267
|
+
onPointerUp: (event) => {
|
|
268
|
+
if (startY.current === null) return;
|
|
269
|
+
const distance = event.clientY - startY.current;
|
|
270
|
+
dragged.current = Math.abs(distance) > 10;
|
|
271
|
+
startY.current = null;
|
|
272
|
+
if (distance > 80) onOpenChange(false);
|
|
273
|
+
else if (distance < -40) setExpanded(true);
|
|
274
|
+
},
|
|
275
|
+
children: /* @__PURE__ */ jsx("span", { className: "ios-grabber-mark" })
|
|
276
|
+
}
|
|
277
|
+
),
|
|
278
|
+
/* @__PURE__ */ jsxs("div", { className: "ios-sheet-heading", children: [
|
|
279
|
+
/* @__PURE__ */ jsx("h2", { id: titleId, tabIndex: -1, children: title }),
|
|
280
|
+
/* @__PURE__ */ jsx(Button, { clear: true, onClick: () => onOpenChange(false), children: "Cancel" })
|
|
281
|
+
] }),
|
|
282
|
+
/* @__PURE__ */ jsx("div", { className: "ios-sheet-content", children })
|
|
283
|
+
] })
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
function IosPage(props) {
|
|
288
|
+
return /* @__PURE__ */ jsx(Page, { ...props });
|
|
289
|
+
}
|
|
290
|
+
function IosBackLink({ component = "button", ...props }) {
|
|
291
|
+
return /* @__PURE__ */ jsx(NavbarBackLink, { component, ...props });
|
|
292
|
+
}
|
|
293
|
+
function IosTab({ component = "button", active, ...props }) {
|
|
294
|
+
return /* @__PURE__ */ jsx(TabbarLink, { component, active, "aria-current": active ? "page" : void 0, ...props });
|
|
295
|
+
}
|
|
296
|
+
function IosNavbar({ titleClassName = "", titleLargeFontSizeIos = "text-[34px]", ...props }) {
|
|
297
|
+
return /* @__PURE__ */ jsx(
|
|
298
|
+
Navbar,
|
|
299
|
+
{
|
|
300
|
+
...props,
|
|
301
|
+
titleClassName: `ios-navigation-title ${titleClassName}`,
|
|
302
|
+
titleLargeFontSizeIos: `ios-navigation-title ${titleLargeFontSizeIos}`
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
function IosTabbar({ children, className = "", colors, ...props }) {
|
|
307
|
+
return /* @__PURE__ */ jsx(Tabbar, { labels: true, icons: true, component: "nav", ...props, className: `native-tabbar ${className}`, colors: { bgIos: "native-tab-scrim", ...colors }, children: /* @__PURE__ */ jsx(Glass, { className: "native-tab-glass", children }) });
|
|
308
|
+
}
|
|
309
|
+
function IosIconButton({ className = "", ...props }) {
|
|
310
|
+
return /* @__PURE__ */ jsx(Button, { inline: true, rounded: true, clear: true, ...props, className: `ios-icon-action ${className}` });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export { IosBackLink, IosIconButton, IosNavbar, IosPage, IosSheet, IosTab, IosTabbar, NativeApp, NavigationStack, iosThemeStyle, useNavigation };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { Ref, ReactEventHandler, RefObject } from 'react';
|
|
3
|
+
|
|
4
|
+
declare const phoneSizes: {
|
|
5
|
+
readonly compact: {
|
|
6
|
+
readonly width: 375;
|
|
7
|
+
readonly height: 812;
|
|
8
|
+
};
|
|
9
|
+
/** A current 6.3" iPhone Pro. */
|
|
10
|
+
readonly standard: {
|
|
11
|
+
readonly width: 402;
|
|
12
|
+
readonly height: 874;
|
|
13
|
+
};
|
|
14
|
+
/** A current 6.9" iPhone Pro Max. */
|
|
15
|
+
readonly large: {
|
|
16
|
+
readonly width: 440;
|
|
17
|
+
readonly height: 956;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A logical iPhone viewport in Sable's Phone frame, scaled down to fit its host.
|
|
22
|
+
*
|
|
23
|
+
* The prototype is a separate document in an iframe, so Konsta's theme and the
|
|
24
|
+
* prototype's fixed overlays, safe areas and fonts never touch the host page.
|
|
25
|
+
* The iframe keeps the logical size (402×874 by default); the whole device is
|
|
26
|
+
* scaled, never the viewport, so the prototype lays out as it would on a phone.
|
|
27
|
+
* The document draws its own status bar, island and home indicator, so the
|
|
28
|
+
* frame draws neither.
|
|
29
|
+
*/
|
|
30
|
+
declare function PhoneFrame({ src, title, size, frameRef, onLoad, }: {
|
|
31
|
+
src: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
size?: keyof typeof phoneSizes;
|
|
34
|
+
frameRef?: Ref<HTMLIFrameElement>;
|
|
35
|
+
onLoad?: ReactEventHandler<HTMLIFrameElement>;
|
|
36
|
+
}): react.JSX.Element;
|
|
37
|
+
|
|
38
|
+
type IosPreviewTheme = {
|
|
39
|
+
type: 'sable:ios-theme';
|
|
40
|
+
mode: 'light' | 'dark';
|
|
41
|
+
tokens: Record<string, string>;
|
|
42
|
+
fonts: string[];
|
|
43
|
+
icons: 'reicon' | 'fallback';
|
|
44
|
+
reducedMotion: boolean;
|
|
45
|
+
};
|
|
46
|
+
declare function isIosPreviewTheme(value: unknown): value is IosPreviewTheme;
|
|
47
|
+
declare function loadIosPreviewFonts(fonts: string[]): void;
|
|
48
|
+
/** Same-origin frames receive computed tokens and fonts, never competing global CSS. */
|
|
49
|
+
declare function useIosPreviewBridge(frame: RefObject<HTMLIFrameElement | null>, { mode, icons, reducedMotion, revision, }: {
|
|
50
|
+
mode: 'light' | 'dark';
|
|
51
|
+
icons?: 'reicon' | 'fallback';
|
|
52
|
+
reducedMotion?: boolean;
|
|
53
|
+
revision?: unknown;
|
|
54
|
+
}): () => void;
|
|
55
|
+
/**
|
|
56
|
+
* The prototype side of the bridge: accept the host's look, set its tokens on
|
|
57
|
+
* <html> (where Sable's styles and the iOS adapter both read them), load its
|
|
58
|
+
* fonts, and return it for mode, icons and motion. Konsta chrome pins its own
|
|
59
|
+
* spacing and radii, so the host's density reaches Sable content only.
|
|
60
|
+
* Returns null until the host has sent a look; render the document's defaults.
|
|
61
|
+
*/
|
|
62
|
+
declare function useIosPreviewTheme(): IosPreviewTheme | null;
|
|
63
|
+
|
|
64
|
+
export { type IosPreviewTheme, PhoneFrame, isIosPreviewTheme, loadIosPreviewFonts, phoneSizes, useIosPreviewBridge, useIosPreviewTheme };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Phone } from '../chunk-RWP4XBSK.js';
|
|
2
|
+
import '../chunk-NEQNXI2G.js';
|
|
3
|
+
import '../chunk-SRQWQUTW.js';
|
|
4
|
+
import { useRef, useState, useEffect, useCallback } from 'react';
|
|
5
|
+
import { jsx } from 'react/jsx-runtime';
|
|
6
|
+
|
|
7
|
+
var phoneSizes = {
|
|
8
|
+
compact: { width: 375, height: 812 },
|
|
9
|
+
/** A current 6.3" iPhone Pro. */
|
|
10
|
+
standard: { width: 402, height: 874 },
|
|
11
|
+
/** A current 6.9" iPhone Pro Max. */
|
|
12
|
+
large: { width: 440, height: 956 }
|
|
13
|
+
};
|
|
14
|
+
function PhoneFrame({
|
|
15
|
+
src,
|
|
16
|
+
title = "iPhone prototype",
|
|
17
|
+
size = "standard",
|
|
18
|
+
frameRef,
|
|
19
|
+
onLoad
|
|
20
|
+
}) {
|
|
21
|
+
const host = useRef(null);
|
|
22
|
+
const device = useRef(null);
|
|
23
|
+
const [fit, setFit] = useState({ scale: 1, width: 0, height: 0 });
|
|
24
|
+
const { width, height } = phoneSizes[size];
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const hostEl = host.current;
|
|
27
|
+
const deviceEl = device.current;
|
|
28
|
+
if (!hostEl || !deviceEl) return;
|
|
29
|
+
const measure = () => {
|
|
30
|
+
const natural = { width: deviceEl.offsetWidth, height: deviceEl.offsetHeight };
|
|
31
|
+
const scale = natural.width ? Math.min(1, hostEl.clientWidth / natural.width) : 1;
|
|
32
|
+
setFit({ scale, ...natural });
|
|
33
|
+
};
|
|
34
|
+
const observer = new ResizeObserver(measure);
|
|
35
|
+
observer.observe(hostEl);
|
|
36
|
+
observer.observe(deviceEl);
|
|
37
|
+
return () => observer.disconnect();
|
|
38
|
+
}, []);
|
|
39
|
+
return /* @__PURE__ */ jsx(
|
|
40
|
+
"div",
|
|
41
|
+
{
|
|
42
|
+
ref: host,
|
|
43
|
+
className: "ios-preview-host",
|
|
44
|
+
style: { maxWidth: fit.width || void 0, height: fit.height ? fit.height * fit.scale : height },
|
|
45
|
+
children: /* @__PURE__ */ jsx(
|
|
46
|
+
"div",
|
|
47
|
+
{
|
|
48
|
+
ref: device,
|
|
49
|
+
className: "ios-phone-frame",
|
|
50
|
+
style: { width: width / 0.926, transform: `scale(${fit.scale})` },
|
|
51
|
+
children: /* @__PURE__ */ jsx(Phone, { statusBar: false, systemChrome: false, className: "w-full", children: /* @__PURE__ */ jsx("iframe", { ref: frameRef, onLoad, src, title, className: "ios-phone-screen" }) })
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
function isIosPreviewTheme(value) {
|
|
58
|
+
if (!value || typeof value !== "object") return false;
|
|
59
|
+
const data = value;
|
|
60
|
+
return data.type === "sable:ios-theme" && (data.mode === "light" || data.mode === "dark") && (data.icons === "reicon" || data.icons === "fallback") && typeof data.reducedMotion === "boolean" && !!data.tokens && !Array.isArray(data.tokens) && typeof data.tokens === "object" && Object.entries(data.tokens).every(([name, token]) => /^--[\w-]+$/.test(name) && typeof token === "string") && Array.isArray(data.fonts) && data.fonts.every((url) => typeof url === "string" && url.startsWith("https://fonts.googleapis.com/"));
|
|
61
|
+
}
|
|
62
|
+
function loadIosPreviewFonts(fonts) {
|
|
63
|
+
for (const url of fonts) {
|
|
64
|
+
if (!url.startsWith("https://fonts.googleapis.com/")) continue;
|
|
65
|
+
if (Array.from(document.querySelectorAll('link[rel="stylesheet"]')).some((link2) => link2.href === url)) continue;
|
|
66
|
+
const link = document.createElement("link");
|
|
67
|
+
link.rel = "stylesheet";
|
|
68
|
+
link.href = url;
|
|
69
|
+
document.head.appendChild(link);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function useIosPreviewBridge(frame, {
|
|
73
|
+
mode,
|
|
74
|
+
icons = "fallback",
|
|
75
|
+
reducedMotion = false,
|
|
76
|
+
revision
|
|
77
|
+
}) {
|
|
78
|
+
const sync = useCallback(() => {
|
|
79
|
+
const computed = getComputedStyle(document.documentElement);
|
|
80
|
+
const message = {
|
|
81
|
+
type: "sable:ios-theme",
|
|
82
|
+
mode,
|
|
83
|
+
icons,
|
|
84
|
+
reducedMotion,
|
|
85
|
+
tokens: Object.fromEntries(Array.from(computed).filter((name) => name.startsWith("--")).map((name) => [name, computed.getPropertyValue(name).trim()])),
|
|
86
|
+
fonts: Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((link) => link.href).filter((url) => url.startsWith("https://fonts.googleapis.com/"))
|
|
87
|
+
};
|
|
88
|
+
frame.current?.contentWindow?.postMessage(message, location.origin);
|
|
89
|
+
}, [frame, mode, icons, reducedMotion]);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
let pending = 0;
|
|
92
|
+
const schedule = () => {
|
|
93
|
+
cancelAnimationFrame(pending);
|
|
94
|
+
pending = requestAnimationFrame(sync);
|
|
95
|
+
};
|
|
96
|
+
const receive = (event) => {
|
|
97
|
+
if (event.origin === location.origin && event.source === frame.current?.contentWindow && event.data?.type === "sable:ios-ready") schedule();
|
|
98
|
+
};
|
|
99
|
+
schedule();
|
|
100
|
+
window.addEventListener("message", receive);
|
|
101
|
+
const observer = new MutationObserver(schedule);
|
|
102
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-sable-theme", "style"] });
|
|
103
|
+
observer.observe(document.head, { childList: true, subtree: true, characterData: true });
|
|
104
|
+
return () => {
|
|
105
|
+
cancelAnimationFrame(pending);
|
|
106
|
+
observer.disconnect();
|
|
107
|
+
window.removeEventListener("message", receive);
|
|
108
|
+
};
|
|
109
|
+
}, [frame, sync, revision]);
|
|
110
|
+
return sync;
|
|
111
|
+
}
|
|
112
|
+
function useIosPreviewTheme() {
|
|
113
|
+
const [look, setLook] = useState(null);
|
|
114
|
+
const applied = useRef([]);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const receive = (event) => {
|
|
117
|
+
if (event.origin !== location.origin || event.source !== window.parent || !isIosPreviewTheme(event.data)) return;
|
|
118
|
+
const root = document.documentElement;
|
|
119
|
+
const { tokens } = event.data;
|
|
120
|
+
for (const name of applied.current) if (!(name in tokens)) root.style.removeProperty(name);
|
|
121
|
+
for (const [name, value] of Object.entries(tokens)) root.style.setProperty(name, value);
|
|
122
|
+
applied.current = Object.keys(tokens);
|
|
123
|
+
loadIosPreviewFonts(event.data.fonts);
|
|
124
|
+
setLook(event.data);
|
|
125
|
+
};
|
|
126
|
+
window.addEventListener("message", receive);
|
|
127
|
+
if (window.parent !== window) window.parent.postMessage({ type: "sable:ios-ready" }, location.origin);
|
|
128
|
+
return () => window.removeEventListener("message", receive);
|
|
129
|
+
}, []);
|
|
130
|
+
return look;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export { PhoneFrame, isIosPreviewTheme, loadIosPreviewFonts, phoneSizes, useIosPreviewBridge, useIosPreviewTheme };
|