@swiftbrowser/web 0.1.0 → 0.2.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/README.md +43 -6
- package/app/index.html +11 -0
- package/node/dist/node/apps.d.ts +16 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +9 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +14 -1
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +20 -3
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/examples.d.ts +2 -1
- package/node/dist/node/examples.d.ts.map +1 -1
- package/node/dist/node/examples.js +11 -2
- package/node/dist/node/examples.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +4 -1
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +3 -2
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/asset-catalogs.d.ts +15 -3
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
- package/node/dist/plugins/asset-catalogs.js +111 -13
- package/node/dist/plugins/asset-catalogs.js.map +1 -1
- package/node/dist/plugins/included-sites.d.ts +26 -0
- package/node/dist/plugins/included-sites.d.ts.map +1 -0
- package/node/dist/plugins/included-sites.js +70 -0
- package/node/dist/plugins/included-sites.js.map +1 -0
- package/node/dist/plugins/pdfImages.d.ts +27 -0
- package/node/dist/plugins/pdfImages.d.ts.map +1 -0
- package/node/dist/plugins/pdfImages.js +145 -0
- package/node/dist/plugins/pdfImages.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +19 -1
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
- package/node/dist/plugins/swift-wasm.js +45 -7
- package/node/dist/plugins/swift-wasm.js.map +1 -1
- package/node/dist/plugins/symbolTemplates.d.ts +22 -0
- package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
- package/node/dist/plugins/symbolTemplates.js +83 -0
- package/node/dist/plugins/symbolTemplates.js.map +1 -0
- package/package.json +4 -2
- package/plugins/app-manifests.ts +3 -2
- package/plugins/asset-catalogs.ts +119 -14
- package/plugins/included-sites.ts +74 -0
- package/plugins/pdfImages.ts +163 -0
- package/plugins/swift-wasm.ts +52 -6
- package/plugins/symbolTemplates.ts +88 -0
- package/public/_headers +7 -0
- package/public/app/oauth-callback.html +62 -0
- package/src/animation.ts +235 -8
- package/src/appFiles.ts +201 -0
- package/src/base64.ts +43 -0
- package/src/bitmapPlan.ts +143 -0
- package/src/bitmapStore.ts +206 -0
- package/src/bitmapWorker.ts +41 -0
- package/src/bitmapWorkerProtocol.ts +15 -0
- package/src/bitmaps.ts +283 -0
- package/src/blockedHosts.ts +29 -0
- package/src/canvasDrawing.ts +361 -0
- package/src/chartDom.ts +2 -2
- package/src/chromeColor.ts +179 -0
- package/src/clipboard.ts +78 -0
- package/src/colorMultiply.ts +93 -0
- package/src/colorPicker.ts +44 -0
- package/src/corsProxy.ts +45 -0
- package/src/gaugeRing.ts +74 -0
- package/src/gif.ts +297 -0
- package/src/hostRequests.ts +426 -0
- package/src/imageOps.ts +592 -0
- package/src/interaction.ts +18 -1
- package/src/landing.ts +2 -2
- package/src/layout/chart.ts +9 -3
- package/src/layout/engine.ts +279 -25
- package/src/layout/index.ts +3 -1
- package/src/layout/text.ts +217 -1
- package/src/layout/types.ts +31 -0
- package/src/layout/typography.ts +24 -7
- package/src/layout/video.ts +24 -0
- package/src/layoutDom.ts +53 -3
- package/src/main.ts +124 -4
- package/src/media.ts +132 -0
- package/src/mock.ts +2 -1
- package/src/notifications.ts +109 -0
- package/src/pathShape.ts +156 -0
- package/src/protocol.ts +761 -12
- package/src/renderer.ts +584 -36
- package/src/richText.ts +65 -0
- package/src/scanner.ts +234 -0
- package/src/scrollTo.ts +123 -0
- package/src/styles.css +212 -7
- package/src/styles.ts +31 -0
- package/src/swiftData.ts +128 -0
- package/src/symbols.ts +564 -21
- package/src/translation.ts +126 -0
- package/src/video.ts +299 -0
- package/src/vite-env.d.ts +6 -0
- package/src/wasm.ts +227 -32
- package/src/webAuth.ts +267 -0
- package/src/webSocket.ts +148 -0
- package/src/zoneinfo.ts +269 -0
package/src/styles.css
CHANGED
|
@@ -77,6 +77,15 @@
|
|
|
77
77
|
--sb-color-secondary-fill: rgba(120, 120, 128, 0.16);
|
|
78
78
|
--sb-color-tertiary-fill: rgba(118, 118, 128, 0.12);
|
|
79
79
|
--sb-color-quaternary-fill: rgba(116, 116, 128, 0.08);
|
|
80
|
+
/* UIKit's semantic colors without a token above (`Color(uiColor:)`). */
|
|
81
|
+
--sb-color-tertiary-system-background: #ffffff;
|
|
82
|
+
--sb-color-tertiary-system-grouped-background: #f2f2f7;
|
|
83
|
+
--sb-color-opaque-separator: #c6c6c8;
|
|
84
|
+
--sb-color-system-gray2: #aeaeb2;
|
|
85
|
+
--sb-color-system-gray3: #c7c7cc;
|
|
86
|
+
--sb-color-system-gray4: #d1d1d6;
|
|
87
|
+
--sb-color-system-gray5: #e5e5ea;
|
|
88
|
+
--sb-color-system-gray6: #f2f2f7;
|
|
80
89
|
/* Phase 6 materials: rgb components of the color blurred over the backdrop. */
|
|
81
90
|
--sb-color-material-base: 255, 255, 255;
|
|
82
91
|
--sb-color-switch-off: #e9e9ea;
|
|
@@ -110,6 +119,14 @@
|
|
|
110
119
|
--sb-color-secondary-fill: rgba(120, 120, 128, 0.32);
|
|
111
120
|
--sb-color-tertiary-fill: rgba(118, 118, 128, 0.24);
|
|
112
121
|
--sb-color-quaternary-fill: rgba(118, 118, 128, 0.18);
|
|
122
|
+
--sb-color-tertiary-system-background: #2c2c2e;
|
|
123
|
+
--sb-color-tertiary-system-grouped-background: #2c2c2e;
|
|
124
|
+
--sb-color-opaque-separator: #38383a;
|
|
125
|
+
--sb-color-system-gray2: #636366;
|
|
126
|
+
--sb-color-system-gray3: #48484a;
|
|
127
|
+
--sb-color-system-gray4: #3a3a3c;
|
|
128
|
+
--sb-color-system-gray5: #2c2c2e;
|
|
129
|
+
--sb-color-system-gray6: #1c1c1e;
|
|
113
130
|
--sb-color-material-base: 30, 30, 30;
|
|
114
131
|
--sb-color-switch-off: #39393d;
|
|
115
132
|
--sb-color-field-border: #3a3a3c;
|
|
@@ -455,7 +472,21 @@ html[data-sb-mode="device"] body {
|
|
|
455
472
|
overflow: hidden;
|
|
456
473
|
overscroll-behavior: none;
|
|
457
474
|
touch-action: manipulation;
|
|
458
|
-
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/* The page behind the app is the OS appearance's system background from the
|
|
478
|
+
first paint (Safari tints its status bar strip from it); chromeColor.ts
|
|
479
|
+
then keeps it at the color the app draws at its top edge. */
|
|
480
|
+
html[data-sb-mode="device"],
|
|
481
|
+
html[data-sb-mode="device"] body {
|
|
482
|
+
background: #ffffff;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
@media (prefers-color-scheme: dark) {
|
|
486
|
+
html[data-sb-mode="device"],
|
|
487
|
+
html[data-sb-mode="device"] body {
|
|
488
|
+
background: #000000;
|
|
489
|
+
}
|
|
459
490
|
}
|
|
460
491
|
|
|
461
492
|
html[data-sb-mode="device"] .toolbar {
|
|
@@ -542,6 +573,9 @@ html[data-sb-mode="device"] .status {
|
|
|
542
573
|
-webkit-background-clip: var(--sb-text-clip, border-box);
|
|
543
574
|
background-clip: var(--sb-text-clip, border-box);
|
|
544
575
|
color: var(--sb-text-color);
|
|
576
|
+
/* View-level `.underline()` / `.strikethrough()` (renderer.ts `applyStyle`);
|
|
577
|
+
with both unset the declaration is invalid and the line is `none`. */
|
|
578
|
+
text-decoration-line: var(--sb-text-underline,) var(--sb-text-strikethrough,);
|
|
545
579
|
}
|
|
546
580
|
|
|
547
581
|
.sb-vstack,
|
|
@@ -639,7 +673,8 @@ html[data-sb-mode="device"] .status {
|
|
|
639
673
|
/* Phase 5: a catalog image. The layout engine decides the frame (intrinsic
|
|
640
674
|
size, or the proposal for `.resizable()`), so the bitmap fills the box;
|
|
641
675
|
`.aspectRatio` fitting is a layout concern, not object-fit's. */
|
|
642
|
-
.sb-image > img
|
|
676
|
+
.sb-image > img,
|
|
677
|
+
.sb-image > canvas {
|
|
643
678
|
display: block;
|
|
644
679
|
width: 100%;
|
|
645
680
|
height: 100%;
|
|
@@ -649,6 +684,12 @@ html[data-sb-mode="device"] .status {
|
|
|
649
684
|
-webkit-user-select: none;
|
|
650
685
|
}
|
|
651
686
|
|
|
687
|
+
/* `.interpolation(.none)`: a QR code's modules stay sharp when scaled up. */
|
|
688
|
+
.sb-image[data-sb-interpolation='none'] > img,
|
|
689
|
+
.sb-image[data-sb-interpolation='none'] > canvas {
|
|
690
|
+
image-rendering: pixelated;
|
|
691
|
+
}
|
|
692
|
+
|
|
652
693
|
/* Phase 5: a `.symbolset` SVG drawn as a template: the SVG masks a box
|
|
653
694
|
filled with the current foreground color, like an SF Symbol. */
|
|
654
695
|
.sb-image > .sb-image-mask {
|
|
@@ -854,6 +895,34 @@ html[data-sb-mode="device"] .status {
|
|
|
854
895
|
border-bottom-right-radius: 10px;
|
|
855
896
|
}
|
|
856
897
|
|
|
898
|
+
/* Lazy rows not built yet (a trailing `lazyspacer`): no cell of their own, and
|
|
899
|
+
the row before them ends the card as the last row would. */
|
|
900
|
+
.sb-lazyspacer {
|
|
901
|
+
background: none !important;
|
|
902
|
+
pointer-events: none;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
.sb-list-content > :not(.sb-section):has(+ .sb-lazyspacer:last-child) {
|
|
906
|
+
border-bottom-left-radius: 10px;
|
|
907
|
+
border-bottom-right-radius: 10px;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
.sb-section-rows > :has(+ .sb-lazyspacer:last-child)::after,
|
|
911
|
+
.sb-list-content > :has(+ .sb-lazyspacer:last-child)::after,
|
|
912
|
+
.sb-section-rows > .sb-lazyspacer::after,
|
|
913
|
+
.sb-list-content > .sb-lazyspacer::after {
|
|
914
|
+
display: none;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/* `.listRowSeparator(.hidden)`: the row's own line (below it) or the
|
|
918
|
+
previous row's (above it) is left out. */
|
|
919
|
+
.sb-section-rows > [data-sb-separator-bottom="hidden"]::after,
|
|
920
|
+
.sb-list-content > [data-sb-separator-bottom="hidden"]::after,
|
|
921
|
+
.sb-section-rows > :has(+ [data-sb-separator-top="hidden"])::after,
|
|
922
|
+
.sb-list-content > :has(+ [data-sb-separator-top="hidden"])::after {
|
|
923
|
+
display: none !important;
|
|
924
|
+
}
|
|
925
|
+
|
|
857
926
|
/* Hairline separators, inset 16px from the leading edge. */
|
|
858
927
|
.sb-section-rows > :not(:last-child)::after,
|
|
859
928
|
.sb-list-content > :not(.sb-section):not(:last-child):not(:has(+ .sb-section))::after {
|
|
@@ -902,7 +971,7 @@ html[data-sb-mode="device"] .status {
|
|
|
902
971
|
|
|
903
972
|
/* A screen with a list as its content takes the grouped background, like a
|
|
904
973
|
UINavigationController hosting an inset-grouped table view. */
|
|
905
|
-
.sb-navscreen:has(> .sb-navscreen-body > .sb-navscreen-content > .sb-list) {
|
|
974
|
+
.sb-navscreen:has(> .sb-navscreen-body > .sb-navscreen-content > .sb-list:not([data-sb-background="hidden"])) {
|
|
906
975
|
background: var(--sb-color-grouped-background);
|
|
907
976
|
}
|
|
908
977
|
|
|
@@ -1070,7 +1139,9 @@ html[data-sb-mode="device"] .status {
|
|
|
1070
1139
|
}
|
|
1071
1140
|
|
|
1072
1141
|
/* NavigationLink: a tappable label. Inside a list it is a row with a chevron
|
|
1073
|
-
at the engine's `chevron` rect
|
|
1142
|
+
at the engine's `chevron` rect, its label in the primary color (also when
|
|
1143
|
+
the row is wrapped: a selectable or deletable `listrow`, a modifier);
|
|
1144
|
+
elsewhere it reads as plain accent text. */
|
|
1074
1145
|
.sb-navlink {
|
|
1075
1146
|
color: var(--sb-color-accent);
|
|
1076
1147
|
cursor: pointer;
|
|
@@ -1094,9 +1165,16 @@ html[data-sb-mode="device"] .status {
|
|
|
1094
1165
|
display: none;
|
|
1095
1166
|
}
|
|
1096
1167
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1168
|
+
/* An explicit `.foregroundStyle` on or around the link (which sets
|
|
1169
|
+
`--sb-button-color`) wins, as in SwiftUI. */
|
|
1170
|
+
.sb-list .sb-navlink {
|
|
1171
|
+
color: var(--sb-button-color, var(--sb-color-primary));
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/* A `Label`'s icon in a list row takes the tint; its title keeps the row's
|
|
1175
|
+
color. */
|
|
1176
|
+
.sb-list [data-sb-label-icon] {
|
|
1177
|
+
color: var(--sb-button-color, var(--sb-tint, var(--sb-color-accent)));
|
|
1100
1178
|
}
|
|
1101
1179
|
|
|
1102
1180
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -1406,6 +1484,30 @@ html[data-sb-mode="device"] .status {
|
|
|
1406
1484
|
white-space: nowrap;
|
|
1407
1485
|
}
|
|
1408
1486
|
|
|
1487
|
+
/* iOS 18 `Tab(…).badge(…)`: a red capsule at the top trailing corner of the icon. */
|
|
1488
|
+
.sb-tabbar-badge {
|
|
1489
|
+
position: absolute;
|
|
1490
|
+
top: 2px;
|
|
1491
|
+
left: calc(50% + 5px);
|
|
1492
|
+
box-sizing: border-box;
|
|
1493
|
+
min-width: 18px;
|
|
1494
|
+
height: 18px;
|
|
1495
|
+
padding: 0 5px;
|
|
1496
|
+
border-radius: 9px;
|
|
1497
|
+
background: var(--sb-color-red);
|
|
1498
|
+
color: #fff;
|
|
1499
|
+
font-size: 13px;
|
|
1500
|
+
line-height: 18px;
|
|
1501
|
+
font-weight: 400;
|
|
1502
|
+
text-align: center;
|
|
1503
|
+
white-space: nowrap;
|
|
1504
|
+
pointer-events: none;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
.sb-tabbar-badge[hidden] {
|
|
1508
|
+
display: none;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1409
1511
|
/* ---------------------------------------------------------------------------
|
|
1410
1512
|
Phase 4: pickers. Segmented: a gray track with equal segments at the
|
|
1411
1513
|
engine's `segment<i>` rects; the selected one carries a raised pill inset
|
|
@@ -1658,6 +1760,28 @@ html[data-sb-mode="device"] .status {
|
|
|
1658
1760
|
background: var(--sb-tint, var(--sb-color-accent));
|
|
1659
1761
|
}
|
|
1660
1762
|
|
|
1763
|
+
.sb-gauge-ring {
|
|
1764
|
+
position: absolute;
|
|
1765
|
+
pointer-events: none;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
.sb-gauge-ring > svg {
|
|
1769
|
+
display: block;
|
|
1770
|
+
width: 100%;
|
|
1771
|
+
height: 100%;
|
|
1772
|
+
overflow: visible;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
.sb-gauge-ring-track {
|
|
1776
|
+
stroke: var(--sb-color-fill);
|
|
1777
|
+
stroke-linecap: round;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
.sb-gauge-ring-value {
|
|
1781
|
+
stroke: var(--sb-tint, var(--sb-color-accent));
|
|
1782
|
+
stroke-linecap: round;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1661
1785
|
@media (prefers-reduced-motion: reduce) {
|
|
1662
1786
|
.sb-picker-menu,
|
|
1663
1787
|
.sb-segment::before {
|
|
@@ -2832,3 +2956,84 @@ html[data-sb-mode="device"] .status {
|
|
|
2832
2956
|
animation-duration: 0.01s;
|
|
2833
2957
|
}
|
|
2834
2958
|
}
|
|
2959
|
+
|
|
2960
|
+
/* Rich text (src/richText.ts): a block per line, the height the engine gave
|
|
2961
|
+
it; fragments share its baseline. An inline image sits on the baseline. */
|
|
2962
|
+
.sb-text-line {
|
|
2963
|
+
display: block;
|
|
2964
|
+
white-space: pre;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
.sb-text-image {
|
|
2968
|
+
display: inline-block;
|
|
2969
|
+
position: relative;
|
|
2970
|
+
vertical-align: baseline;
|
|
2971
|
+
overflow: hidden;
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
/* A symbol's glyph is centered in its line-height square, as a standalone
|
|
2975
|
+
`Image(systemName:)` is. */
|
|
2976
|
+
.sb-text-symbol {
|
|
2977
|
+
display: inline-flex;
|
|
2978
|
+
vertical-align: text-bottom;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
/* A link: tinted unless the run has its own foreground. */
|
|
2982
|
+
.sb-link {
|
|
2983
|
+
color: var(--sb-tint, var(--sb-color-accent));
|
|
2984
|
+
cursor: pointer;
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
/* ---------------------------------------------------------------------------
|
|
2988
|
+
A vertical text field (`TextField(_:text:axis: .vertical)`): a textarea the
|
|
2989
|
+
engine sizes to its lines, in the font it measured them in (layoutDom.ts);
|
|
2990
|
+
it scrolls inside past its `lineLimit`.
|
|
2991
|
+
------------------------------------------------------------------------- */
|
|
2992
|
+
textarea.sb-textfield {
|
|
2993
|
+
box-sizing: border-box;
|
|
2994
|
+
margin: 0;
|
|
2995
|
+
padding: 0;
|
|
2996
|
+
resize: none;
|
|
2997
|
+
overflow-y: auto;
|
|
2998
|
+
scrollbar-width: none;
|
|
2999
|
+
white-space: pre-wrap;
|
|
3000
|
+
overflow-wrap: break-word;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
textarea.sb-textfield[data-sb-style="roundedBorder"] {
|
|
3004
|
+
padding: 5px 7px;
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
/* A section's view header / footer: footnote-sized secondary text above or
|
|
3008
|
+
below the card (the engine sets the fonts of the texts inside). */
|
|
3009
|
+
.sb-sectionheader,
|
|
3010
|
+
.sb-sectionfooter {
|
|
3011
|
+
color: var(--sb-color-secondary);
|
|
3012
|
+
text-align: left;
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
/* `.scrollContentBackground(.hidden)`: the list's own background goes; rows keep theirs. */
|
|
3016
|
+
.sb-list[data-sb-background="hidden"] {
|
|
3017
|
+
background: transparent;
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
/* A custom shape's path (src/pathShape.ts): an SVG filling the shape's box,
|
|
3021
|
+
whose view box is the size the path was drawn for. Strokes may lie outside
|
|
3022
|
+
the box, as in SwiftUI. */
|
|
3023
|
+
svg.sb-path {
|
|
3024
|
+
display: block;
|
|
3025
|
+
width: 100%;
|
|
3026
|
+
height: 100%;
|
|
3027
|
+
overflow: visible;
|
|
3028
|
+
pointer-events: none;
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
/* A `Canvas` (src/canvasDrawing.ts): its drawing is clipped to its box. */
|
|
3032
|
+
.sb-canvas {
|
|
3033
|
+
overflow: hidden;
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
.sb-canvas > svg {
|
|
3037
|
+
display: block;
|
|
3038
|
+
overflow: hidden;
|
|
3039
|
+
}
|
package/src/styles.ts
CHANGED
|
@@ -41,7 +41,20 @@ const SEMANTIC_COLOR_VAR: Record<string, string> = {
|
|
|
41
41
|
accent: 'var(--sb-color-accent)',
|
|
42
42
|
systemBackground: 'var(--sb-color-system-background)',
|
|
43
43
|
secondarySystemBackground: 'var(--sb-color-secondary-system-background)',
|
|
44
|
+
// UIKit's semantic colors (`Color(uiColor:)`) with a token of another name;
|
|
45
|
+
// the rest map by name (`tertiaryLabel` → `--sb-color-tertiary-label`).
|
|
46
|
+
label: 'var(--sb-color-primary)',
|
|
47
|
+
secondaryLabel: 'var(--sb-color-secondary)',
|
|
48
|
+
systemGroupedBackground: 'var(--sb-color-grouped-background)',
|
|
49
|
+
secondarySystemGroupedBackground: 'var(--sb-color-grouped-cell)',
|
|
50
|
+
placeholderText: 'var(--sb-color-placeholder)',
|
|
51
|
+
systemFill: 'var(--sb-color-fill)',
|
|
52
|
+
secondarySystemFill: 'var(--sb-color-secondary-fill)',
|
|
53
|
+
tertiarySystemFill: 'var(--sb-color-tertiary-fill)',
|
|
54
|
+
quaternarySystemFill: 'var(--sb-color-quaternary-fill)',
|
|
44
55
|
clear: 'transparent',
|
|
56
|
+
// `.tint` as a style: the nearest `.tint(_:)` (styled `tint` sets `--sb-tint`), else the accent.
|
|
57
|
+
tint: 'var(--sb-tint, var(--sb-color-accent))',
|
|
45
58
|
};
|
|
46
59
|
|
|
47
60
|
/** CSS `<color>` for a protocol `Color`. */
|
|
@@ -325,6 +338,24 @@ export function effectsCSS(style: Style): EffectsCSS {
|
|
|
325
338
|
if (style.blur != null && Number.isFinite(style.blur) && style.blur > 0) {
|
|
326
339
|
filters.push(`blur(${round(style.blur)}px)`);
|
|
327
340
|
}
|
|
341
|
+
// `.brightness` (SwiftUI adds the amount; CSS scales by 1 + amount),
|
|
342
|
+
// `.saturation`, `.contrast`, `.grayscale`, `.hueRotation`, `.colorInvert`.
|
|
343
|
+
if (style.brightness != null && Number.isFinite(style.brightness) && style.brightness !== 0) {
|
|
344
|
+
filters.push(`brightness(${round(Math.max(0, 1 + style.brightness))})`);
|
|
345
|
+
}
|
|
346
|
+
if (style.saturation != null && Number.isFinite(style.saturation) && style.saturation !== 1) {
|
|
347
|
+
filters.push(`saturate(${round(Math.max(0, style.saturation))})`);
|
|
348
|
+
}
|
|
349
|
+
if (style.contrast != null && Number.isFinite(style.contrast) && style.contrast !== 1) {
|
|
350
|
+
filters.push(`contrast(${round(Math.max(0, style.contrast))})`);
|
|
351
|
+
}
|
|
352
|
+
if (style.grayscale != null && Number.isFinite(style.grayscale) && style.grayscale > 0) {
|
|
353
|
+
filters.push(`grayscale(${round(Math.min(1, style.grayscale))})`);
|
|
354
|
+
}
|
|
355
|
+
if (style.hueRotation != null && Number.isFinite(style.hueRotation) && style.hueRotation !== 0) {
|
|
356
|
+
filters.push(`hue-rotate(${round(style.hueRotation)}deg)`);
|
|
357
|
+
}
|
|
358
|
+
if (style.colorInvert === true) filters.push('invert(1)');
|
|
328
359
|
if (style.shadow) {
|
|
329
360
|
const { color, radius, x, y } = style.shadow;
|
|
330
361
|
const css = color ? colorToCSS(color) : 'rgba(0, 0, 0, 0.33)';
|
package/src/swiftData.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 18: SwiftData's stores in the browser's IndexedDB.
|
|
3
|
+
*
|
|
4
|
+
* The app keeps its models in memory (Sources/SwiftData) and the page keeps
|
|
5
|
+
* them across launches: before the module starts, every record of the app's
|
|
6
|
+
* database is read and handed in as `SB_SWIFTDATA`
|
|
7
|
+
* (`{"<store>": [{id, entity, values}, …]}`), and each `swiftdata` op (one
|
|
8
|
+
* save) is written back in one transaction. One database per app,
|
|
9
|
+
* `sb-swiftdata:<App>`, with one object store, `records`, keyed by
|
|
10
|
+
* `[store, id]`. See docs/ops-protocol.md, "Phase 18".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SwiftDataOp, SwiftDataRecord } from './protocol';
|
|
14
|
+
|
|
15
|
+
/** Every store of an app: store name → its records, in key order. */
|
|
16
|
+
export type SwiftDataStores = Record<string, SwiftDataRecord[]>;
|
|
17
|
+
|
|
18
|
+
/** What is kept in the `records` object store. */
|
|
19
|
+
interface StoredRecord extends SwiftDataRecord {
|
|
20
|
+
store: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const RECORDS = 'records';
|
|
24
|
+
|
|
25
|
+
export function databaseName(app: string): string {
|
|
26
|
+
return `sb-swiftdata:${app}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `stores` with one save applied, as the database applies it (for tests and the headless runner). */
|
|
30
|
+
export function applySwiftDataOp(stores: SwiftDataStores, op: SwiftDataOp): SwiftDataStores {
|
|
31
|
+
const records = new Map<number, SwiftDataRecord>();
|
|
32
|
+
if (!op.clear) for (const record of stores[op.store] ?? []) records.set(record.id, record);
|
|
33
|
+
for (const id of op.delete) records.delete(id);
|
|
34
|
+
for (const record of op.put) records.set(record.id, { id: record.id, entity: record.entity, values: record.values });
|
|
35
|
+
return { ...stores, [op.store]: [...records.values()].sort((a, b) => a.id - b.id) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The stores as the module reads them from `SB_SWIFTDATA`; null when there is nothing to hand in. */
|
|
39
|
+
export function launchValue(stores: SwiftDataStores): string | null {
|
|
40
|
+
const nonEmpty = Object.entries(stores).filter(([, records]) => records.length > 0);
|
|
41
|
+
if (nonEmpty.length === 0) return null;
|
|
42
|
+
return JSON.stringify(Object.fromEntries(nonEmpty));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Groups stored records by store, each in key order. */
|
|
46
|
+
export function groupRecords(rows: readonly StoredRecord[]): SwiftDataStores {
|
|
47
|
+
const stores: SwiftDataStores = {};
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
(stores[row.store] ??= []).push({ id: row.id, entity: row.entity, values: row.values });
|
|
50
|
+
}
|
|
51
|
+
for (const records of Object.values(stores)) records.sort((a, b) => a.id - b.id);
|
|
52
|
+
return stores;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Where an app's SwiftData lives between launches. */
|
|
56
|
+
export interface SwiftDataPersistence {
|
|
57
|
+
/** Every store, read once before the module starts. */
|
|
58
|
+
load(): Promise<SwiftDataStores>;
|
|
59
|
+
/** Writes one save; saves are written in the order they were made. */
|
|
60
|
+
apply(op: SwiftDataOp): Promise<void>;
|
|
61
|
+
/** Deletes the app's data (every store). */
|
|
62
|
+
clear(): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function request<T>(req: IDBRequest<T>): Promise<T> {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
req.onsuccess = () => resolve(req.result);
|
|
68
|
+
req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed'));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function done(transaction: IDBTransaction): Promise<void> {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
transaction.oncomplete = () => resolve();
|
|
75
|
+
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
|
|
76
|
+
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The app's database in IndexedDB (`factory` is the browser's unless a test passes one). */
|
|
81
|
+
export function indexedDBPersistence(app: string, factory: IDBFactory = indexedDB): SwiftDataPersistence {
|
|
82
|
+
let opened: Promise<IDBDatabase> | null = null;
|
|
83
|
+
const database = (): Promise<IDBDatabase> => {
|
|
84
|
+
opened ??= new Promise((resolve, reject) => {
|
|
85
|
+
const open = factory.open(databaseName(app), 1);
|
|
86
|
+
open.onupgradeneeded = () => {
|
|
87
|
+
if (!open.result.objectStoreNames.contains(RECORDS)) open.result.createObjectStore(RECORDS, { keyPath: ['store', 'id'] });
|
|
88
|
+
};
|
|
89
|
+
open.onsuccess = () => resolve(open.result);
|
|
90
|
+
open.onerror = () => reject(open.error ?? new Error(`could not open ${databaseName(app)}`));
|
|
91
|
+
open.onblocked = () => reject(new Error(`${databaseName(app)} is blocked by another tab`));
|
|
92
|
+
});
|
|
93
|
+
return opened;
|
|
94
|
+
};
|
|
95
|
+
// Saves are chained so that each one's transaction starts after the
|
|
96
|
+
// previous one's was created, whatever the database's opening takes.
|
|
97
|
+
let queue: Promise<void> = Promise.resolve();
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
async load() {
|
|
101
|
+
const db = await database();
|
|
102
|
+
const transaction = db.transaction(RECORDS, 'readonly');
|
|
103
|
+
const rows = await request(transaction.objectStore(RECORDS).getAll() as IDBRequest<StoredRecord[]>);
|
|
104
|
+
return groupRecords(rows);
|
|
105
|
+
},
|
|
106
|
+
apply(op) {
|
|
107
|
+
queue = queue.then(async () => {
|
|
108
|
+
const db = await database();
|
|
109
|
+
const transaction = db.transaction(RECORDS, 'readwrite');
|
|
110
|
+
const records = transaction.objectStore(RECORDS);
|
|
111
|
+
if (op.clear) records.delete(IDBKeyRange.bound([op.store, -Infinity], [op.store, Infinity]));
|
|
112
|
+
for (const id of op.delete) records.delete([op.store, id]);
|
|
113
|
+
for (const record of op.put) {
|
|
114
|
+
const row: StoredRecord = { store: op.store, id: record.id, entity: record.entity, values: record.values };
|
|
115
|
+
records.put(row);
|
|
116
|
+
}
|
|
117
|
+
await done(transaction);
|
|
118
|
+
});
|
|
119
|
+
return queue;
|
|
120
|
+
},
|
|
121
|
+
async clear() {
|
|
122
|
+
const db = await database();
|
|
123
|
+
const transaction = db.transaction(RECORDS, 'readwrite');
|
|
124
|
+
transaction.objectStore(RECORDS).clear();
|
|
125
|
+
await done(transaction);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|