@swiftbrowser/web 0.5.4 → 0.5.6
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/package.json +1 -1
- package/src/chartDom.ts +2 -2
- package/src/layout/engine.ts +35 -8
- package/src/layoutDom.ts +70 -10
- package/src/protocol.ts +3 -2
- package/src/renderer.ts +6 -1
- package/src/styles.css +63 -16
- package/src/styles.ts +17 -2
- package/src/symbols.ts +191 -0
package/package.json
CHANGED
package/src/chartDom.ts
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
type GradientStop,
|
|
46
46
|
type ShapeStyle,
|
|
47
47
|
} from './protocol';
|
|
48
|
-
import {
|
|
48
|
+
import { colorToCSS, materialAlpha, styleToCSS } from './styles';
|
|
49
49
|
|
|
50
50
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
51
51
|
const LINE_WIDTH_DEFAULT = 2;
|
|
@@ -330,7 +330,7 @@ export function paintFor(doc: Document, defs: SVGDefsElement, style: ShapeStyle
|
|
|
330
330
|
if (isColor(style)) return colorToCSS(style);
|
|
331
331
|
if (isHierarchicalStyle(style)) return styleToCSS(style, 'paint').value;
|
|
332
332
|
if (isMaterialStyle(style)) {
|
|
333
|
-
const alpha = (
|
|
333
|
+
const alpha = materialAlpha(style.material) * clamp01(style.opacity ?? 1);
|
|
334
334
|
return `rgba(${MATERIAL_GRAY}, ${r2(alpha * 0.5)})`;
|
|
335
335
|
}
|
|
336
336
|
const opacity = clamp01(style.opacity ?? 1);
|
package/src/layout/engine.ts
CHANGED
|
@@ -276,7 +276,8 @@ function navSlot(item: LayoutNode, width: number): number {
|
|
|
276
276
|
// and 18 either side, 6 between slots; a lone icon is a 48 circle. An item
|
|
277
277
|
// with no text (symbols, a symbol and a chevron, a custom view) is an icon
|
|
278
278
|
// at any width: at least 50, else its width and 8 either side, so a little
|
|
279
|
-
// more width gives it a little more room (not text's 18 a side).
|
|
279
|
+
// more width gives it a little more room (not text's 18 a side). Alone in its
|
|
280
|
+
// group it is a circle only if it fits an icon's slot; wider, a capsule.
|
|
280
281
|
const BOTTOM_GLASS_HEIGHT = 48;
|
|
281
282
|
const BOTTOM_GLASS_MARGIN_H = 28;
|
|
282
283
|
const BOTTOM_ICON_SLOT = 50;
|
|
@@ -1724,7 +1725,7 @@ export class LayoutPass {
|
|
|
1724
1725
|
|
|
1725
1726
|
private buttonIsBordered(node: LayoutNode): boolean {
|
|
1726
1727
|
const style = str(node.props['style']);
|
|
1727
|
-
return style === 'bordered' || style === 'borderedProminent';
|
|
1728
|
+
return style === 'bordered' || style === 'borderedProminent' || style === 'glass';
|
|
1728
1729
|
}
|
|
1729
1730
|
|
|
1730
1731
|
private measureButton(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
@@ -1931,8 +1932,22 @@ export class LayoutPass {
|
|
|
1931
1932
|
rowY += PICKER_INLINE_ROW_HEIGHT;
|
|
1932
1933
|
}
|
|
1933
1934
|
} else {
|
|
1934
|
-
|
|
1935
|
-
|
|
1935
|
+
// Too little room for both: as an HStack shares it, the narrower of
|
|
1936
|
+
// the two is offered half first and keeps what it needs; the other
|
|
1937
|
+
// takes the rest and truncates (a long value, rather than its label).
|
|
1938
|
+
const room = Math.max(0, size.width - PICKER_MENU_GAP);
|
|
1939
|
+
let labelWidth = plan.labelWidth;
|
|
1940
|
+
let valueWidth = plan.valueWidth;
|
|
1941
|
+
if (labelWidth + valueWidth > room) {
|
|
1942
|
+
if (labelWidth <= valueWidth) {
|
|
1943
|
+
labelWidth = Math.min(labelWidth, room / 2);
|
|
1944
|
+
valueWidth = room - labelWidth;
|
|
1945
|
+
} else {
|
|
1946
|
+
valueWidth = Math.min(valueWidth, room / 2);
|
|
1947
|
+
labelWidth = room - valueWidth;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
valueWidth = Math.min(valueWidth, size.width);
|
|
1936
1951
|
chrome['label'] = { x, y, width: labelWidth, height: size.height };
|
|
1937
1952
|
chrome['value'] = { x: x + size.width - valueWidth, y, width: valueWidth, height: size.height };
|
|
1938
1953
|
}
|
|
@@ -2605,10 +2620,21 @@ export class LayoutPass {
|
|
|
2605
2620
|
case 'image':
|
|
2606
2621
|
if (node.id >= 0) this.fonts.set(node.id, scaledFont(ctx.font, ctx.imageScale));
|
|
2607
2622
|
return;
|
|
2608
|
-
case 'textfield':
|
|
2609
|
-
// A
|
|
2610
|
-
|
|
2623
|
+
case 'textfield': {
|
|
2624
|
+
// A field draws its text in the context font, so it sits on the same
|
|
2625
|
+
// baseline as a label in that font beside it (a form row's).
|
|
2626
|
+
if (node.id < 0) return;
|
|
2627
|
+
this.fonts.set(node.id, ctx.font);
|
|
2628
|
+
if (node.props['axis'] === 'vertical') return;
|
|
2629
|
+
// Where a one-line Text centered in the field would sit: snapped with
|
|
2630
|
+
// the frames, it lands where a label centered beside the field does,
|
|
2631
|
+
// and the DOM puts the field's text there (layoutDom.ts). Keep the
|
|
2632
|
+
// list row chrome a list set before placing.
|
|
2633
|
+
const line = ctx.font.lineHeight;
|
|
2634
|
+
const existing = this.chrome.get(node.id);
|
|
2635
|
+
this.setChrome(node, { ...existing, text: { x, y: y + (size.height - line) / 2, width: size.width, height: line } });
|
|
2611
2636
|
return;
|
|
2637
|
+
}
|
|
2612
2638
|
case 'spacer':
|
|
2613
2639
|
case 'lazyspacer':
|
|
2614
2640
|
case 'divider':
|
|
@@ -3428,7 +3454,8 @@ export class LayoutPass {
|
|
|
3428
3454
|
const close = (): void => {
|
|
3429
3455
|
if (open.length === 0) return;
|
|
3430
3456
|
const icon = (item: Item): boolean => item.size.width < NAV_GLASS_SIZE;
|
|
3431
|
-
|
|
3457
|
+
// a lone icon is a circle; a wider one (a symbol and a chevron) keeps its capsule
|
|
3458
|
+
const lone = open.length === 1 && icon(open[0] as Item) && (open[0] as Item).slot <= BOTTOM_ICON_SLOT;
|
|
3432
3459
|
if (lone) (open[0] as Item).slot = BOTTOM_GLASS_HEIGHT;
|
|
3433
3460
|
const slots = open.reduce((sum, item) => sum + item.slot, 0);
|
|
3434
3461
|
tokens.push({ kind: 'group', items: open, width: slots + (open.length - 1) * BOTTOM_ITEM_GAP });
|
package/src/layoutDom.ts
CHANGED
|
@@ -363,18 +363,68 @@ function setFlag(el: HTMLElement, key: string, value: string | null): void {
|
|
|
363
363
|
}
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
/**
|
|
367
|
-
|
|
366
|
+
/**
|
|
367
|
+
* For leaf rows: pad the content frame back into place inside the row box.
|
|
368
|
+
* `shift` moves vertically centered content (a one-line field's text) down
|
|
369
|
+
* (or up, negative) by padding twice as much above it (or below).
|
|
370
|
+
*/
|
|
371
|
+
function padIntoFrame(el: HTMLElement, box: Rect, frame: Rect, shift = 0): void {
|
|
368
372
|
const s = el.style;
|
|
369
373
|
const left = frame.x - box.x;
|
|
370
374
|
const top = frame.y - box.y;
|
|
371
375
|
const right = box.x + box.width - (frame.x + frame.width);
|
|
372
376
|
const bottom = box.y + box.height - (frame.y + frame.height);
|
|
377
|
+
const above = Math.max(0, top) + Math.max(0, 2 * shift);
|
|
378
|
+
const below = Math.max(0, bottom) + Math.max(0, -2 * shift);
|
|
373
379
|
if (left || top || right || bottom) {
|
|
374
|
-
s.padding = `${
|
|
375
|
-
|
|
376
|
-
s.padding = '';
|
|
380
|
+
s.padding = `${above}px ${Math.max(0, right)}px ${below}px ${Math.max(0, left)}px`;
|
|
381
|
+
return;
|
|
377
382
|
}
|
|
383
|
+
// Only the shift: above and below alone, the sides left to the stylesheet
|
|
384
|
+
// (a rounded border field's inset).
|
|
385
|
+
if (s.paddingLeft || s.paddingRight) s.padding = '';
|
|
386
|
+
const paddingTop = above ? `${above}px` : '';
|
|
387
|
+
const paddingBottom = below ? `${below}px` : '';
|
|
388
|
+
if (s.paddingTop !== paddingTop) s.paddingTop = paddingTop;
|
|
389
|
+
if (s.paddingBottom !== paddingBottom) s.paddingBottom = paddingBottom;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const fieldCapShifts = new Map<string, number>();
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* How far a one-line field's text must move so its capitals sit where a
|
|
396
|
+
* text's do: styles.css trims a text's first line to its cap height and puts
|
|
397
|
+
* the capitals `--sb-cap-inset` (SF Pro's 0.2476em) below the line's top,
|
|
398
|
+
* whatever font the page has, but an input lays its line out with the
|
|
399
|
+
* font's own metrics. Where those put the capitals is measured in the page
|
|
400
|
+
* (canvas may resolve a font list to another face): a line trimmed to its
|
|
401
|
+
* cap height is shorter than an untrimmed one by just that. Once per font;
|
|
402
|
+
* none where texts are not trimmed (both then use the font's metrics).
|
|
403
|
+
*/
|
|
404
|
+
function fieldCapShift(font: ResolvedFont): number {
|
|
405
|
+
const cached = fieldCapShifts.get(font.css);
|
|
406
|
+
if (cached !== undefined) return cached;
|
|
407
|
+
let shift = 0;
|
|
408
|
+
if (typeof CSS !== 'undefined' && CSS.supports('text-box', 'trim-start cap alphabetic') && document.body) {
|
|
409
|
+
const probe = document.createElement('div');
|
|
410
|
+
probe.style.cssText = 'position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre';
|
|
411
|
+
const line = (trimmed: boolean): HTMLElement => {
|
|
412
|
+
const el = document.createElement('div');
|
|
413
|
+
el.style.font = font.css;
|
|
414
|
+
if (trimmed) el.style.setProperty('text-box', 'trim-start cap alphabetic');
|
|
415
|
+
el.textContent = 'H';
|
|
416
|
+
return probe.appendChild(el);
|
|
417
|
+
};
|
|
418
|
+
const whole = line(false);
|
|
419
|
+
const trimmed = line(true);
|
|
420
|
+
document.body.appendChild(probe);
|
|
421
|
+
const natural = whole.getBoundingClientRect().height - trimmed.getBoundingClientRect().height;
|
|
422
|
+
probe.remove();
|
|
423
|
+
shift = Math.round((0.2476 * font.size - natural) * 100) / 100;
|
|
424
|
+
}
|
|
425
|
+
// a web font still loading measures as its fallback: measure again next time
|
|
426
|
+
if (typeof document === 'undefined' || !document.fonts || document.fonts.status === 'loaded') fieldCapShifts.set(font.css, shift);
|
|
427
|
+
return shift;
|
|
378
428
|
}
|
|
379
429
|
|
|
380
430
|
function applyChrome(
|
|
@@ -419,12 +469,22 @@ function applyChrome(
|
|
|
419
469
|
return;
|
|
420
470
|
}
|
|
421
471
|
case 'textfield':
|
|
422
|
-
case 'texteditor':
|
|
423
|
-
// A
|
|
424
|
-
// a leading apart.
|
|
425
|
-
|
|
426
|
-
|
|
472
|
+
case 'texteditor': {
|
|
473
|
+
// A field draws in its context's font (a vertical one, the font its
|
|
474
|
+
// lines were measured in, a leading apart).
|
|
475
|
+
const font = result.fonts.get(node.id);
|
|
476
|
+
setFont(el, font, el instanceof HTMLTextAreaElement ? 2 : 1);
|
|
477
|
+
// A one-line field's text sits where a centered Text's would (the
|
|
478
|
+
// engine's `text` rect, snapped as a label beside it is): the input
|
|
479
|
+
// centers its line in the frame, so move it by the difference.
|
|
480
|
+
const line = chrome?.['text'];
|
|
481
|
+
const shift =
|
|
482
|
+
el instanceof HTMLInputElement && font
|
|
483
|
+
? fieldCapShift(font) + (line ? line.y - frame.y - (frame.height - font.lineHeight) / 2 : 0)
|
|
484
|
+
: 0;
|
|
485
|
+
padIntoFrame(el, box, frame, shift);
|
|
427
486
|
return;
|
|
487
|
+
}
|
|
428
488
|
// Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
|
|
429
489
|
case 'stepper':
|
|
430
490
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-stepper-control'), chrome?.['control'], box);
|
package/src/protocol.ts
CHANGED
|
@@ -156,7 +156,8 @@ export interface ColorGradientStyle extends StyleOpacity {
|
|
|
156
156
|
colorGradient: Color;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
/** `glass` / `glassClear`: iOS 26 Liquid Glass (`.glassEffect`), the bar buttons' fill with the glass rim. */
|
|
160
|
+
export type MaterialName = 'ultraThin' | 'thin' | 'regular' | 'thick' | 'ultraThick' | 'glass' | 'glassClear';
|
|
160
161
|
/** A translucent material: the system background at some opacity over a blurred backdrop. */
|
|
161
162
|
export interface MaterialStyle extends StyleOpacity {
|
|
162
163
|
material: MaterialName;
|
|
@@ -463,7 +464,7 @@ export interface HStackProps {
|
|
|
463
464
|
export interface SpacerProps {
|
|
464
465
|
minLength: number | null;
|
|
465
466
|
}
|
|
466
|
-
export type ButtonStyle = 'automatic' | 'plain' | 'bordered' | 'borderedProminent';
|
|
467
|
+
export type ButtonStyle = 'automatic' | 'plain' | 'bordered' | 'borderedProminent' | 'glass';
|
|
467
468
|
/** Phase 8: `cancel` is the role an `alert` draws apart (bold, or in its own card). */
|
|
468
469
|
export type ButtonRole = 'destructive' | 'cancel' | null;
|
|
469
470
|
/**
|
package/src/renderer.ts
CHANGED
|
@@ -583,7 +583,10 @@ export class Renderer {
|
|
|
583
583
|
const active = document.activeElement;
|
|
584
584
|
for (const input of this.root.querySelectorAll<HTMLElement>('.sb-textfield[data-sb-focus], .sb-texteditor[data-sb-focus]')) {
|
|
585
585
|
const wanted = input.dataset['sbFocus'] === 'true';
|
|
586
|
-
|
|
586
|
+
// `preventScroll`: a field in a sheet still sliding in sits below the
|
|
587
|
+
// screen, and the browser would scroll the whole screen to reveal it,
|
|
588
|
+
// blanking the page until something scrolls it back.
|
|
589
|
+
if (wanted && active !== input) input.focus({ preventScroll: true });
|
|
587
590
|
else if (!wanted && active === input) input.blur();
|
|
588
591
|
}
|
|
589
592
|
}
|
|
@@ -3223,6 +3226,8 @@ function applyPaint(el: HTMLElement, paint: PaintCSS): void {
|
|
|
3223
3226
|
s.setProperty('backdrop-filter', paint.backdropFilter);
|
|
3224
3227
|
s.setProperty('-webkit-backdrop-filter', paint.backdropFilter);
|
|
3225
3228
|
}
|
|
3229
|
+
// Liquid Glass gets the rim (styles.css `[data-sb-glass]`).
|
|
3230
|
+
el.toggleAttribute('data-sb-glass', paint.glass === true);
|
|
3226
3231
|
}
|
|
3227
3232
|
|
|
3228
3233
|
// Stacks, spacers and zstacks are pure layout: the engine reads their props
|
package/src/styles.css
CHANGED
|
@@ -108,7 +108,10 @@
|
|
|
108
108
|
--sb-glass-selected: rgba(120, 120, 128, 0.16);
|
|
109
109
|
--sb-color-segment-track: rgba(118, 118, 128, 0.12);
|
|
110
110
|
--sb-color-segment-selected: #ffffff;
|
|
111
|
-
|
|
111
|
+
/* Menus, floating sheets and alerts are Liquid Glass too: translucent
|
|
112
|
+
over a heavier backdrop blur, with the glass rim. */
|
|
113
|
+
--sb-color-menu-background: rgba(250, 250, 250, 0.82);
|
|
114
|
+
--sb-sheet-glass: rgba(255, 255, 255, 0.55);
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
.device[data-theme="dark"] {
|
|
@@ -167,7 +170,9 @@
|
|
|
167
170
|
--sb-color-tabbar-background: rgba(29, 29, 29, 0.94);
|
|
168
171
|
--sb-color-segment-track: rgba(118, 118, 128, 0.24);
|
|
169
172
|
--sb-color-segment-selected: #5a5a5f;
|
|
170
|
-
--sb-color-menu-background: rgba(37, 37, 37, 0.
|
|
173
|
+
--sb-color-menu-background: rgba(37, 37, 37, 0.82);
|
|
174
|
+
/* The iPhone's dark floating sheet reads 42 over black: dark gray glass. */
|
|
175
|
+
--sb-sheet-glass: rgba(60, 60, 60, 0.7);
|
|
171
176
|
}
|
|
172
177
|
|
|
173
178
|
/* ---------------------------------------------------------------------------
|
|
@@ -500,14 +505,30 @@ html[data-sb-mode="device"] .device-screen {
|
|
|
500
505
|
color: #ffffff;
|
|
501
506
|
}
|
|
502
507
|
|
|
508
|
+
/* `.buttonStyle(.glass)`: a Liquid Glass capsule (the bar buttons' fill,
|
|
509
|
+
blur and rim) in the tint. */
|
|
510
|
+
.sb-button[data-sb-style="glass"] {
|
|
511
|
+
border-radius: 9999px;
|
|
512
|
+
background: var(--sb-glass-background);
|
|
513
|
+
box-shadow: var(--sb-glass-shadow);
|
|
514
|
+
backdrop-filter: blur(12px) saturate(180%);
|
|
515
|
+
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
|
516
|
+
color: var(--sb-button-color, var(--sb-tint, var(--sb-color-accent)));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/* `.glassEffect`: a shape painted with the `glass` material (renderer). */
|
|
520
|
+
[data-sb-glass] {
|
|
521
|
+
box-shadow: var(--sb-glass-shadow);
|
|
522
|
+
}
|
|
523
|
+
|
|
503
524
|
/* iOS 26 draws a disabled bordered button gray rather than faded
|
|
504
525
|
(`.disabled` marks the element around the button and dims it inline; the
|
|
505
526
|
renderer keeps the undimmed opacity in `--sb-undimmed-opacity`). */
|
|
506
|
-
[data-sb-disabled]:has(> .sb-button:is([data-sb-style="bordered"], [data-sb-style="borderedProminent"])) {
|
|
527
|
+
[data-sb-disabled]:has(> .sb-button:is([data-sb-style="bordered"], [data-sb-style="borderedProminent"], [data-sb-style="glass"])) {
|
|
507
528
|
opacity: var(--sb-undimmed-opacity, 1) !important;
|
|
508
529
|
}
|
|
509
530
|
|
|
510
|
-
[data-sb-disabled] > .sb-button:is([data-sb-style="bordered"], [data-sb-style="borderedProminent"]) {
|
|
531
|
+
[data-sb-disabled] > .sb-button:is([data-sb-style="bordered"], [data-sb-style="borderedProminent"], [data-sb-style="glass"]) {
|
|
511
532
|
background: var(--sb-color-secondary-fill);
|
|
512
533
|
color: var(--sb-color-tertiary-label);
|
|
513
534
|
}
|
|
@@ -695,12 +716,29 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
695
716
|
font: inherit;
|
|
696
717
|
font-size: var(--sb-font-size-body);
|
|
697
718
|
line-height: 1.3;
|
|
719
|
+
/* the text starts at the field's edge, as SwiftUI's does (no UA padding) */
|
|
720
|
+
padding: 0;
|
|
721
|
+
/* `.multilineTextAlignment` reaches fields as it reaches text (inputs reset it) */
|
|
722
|
+
text-align: inherit;
|
|
698
723
|
color: var(--sb-color-primary);
|
|
699
724
|
background: transparent;
|
|
700
725
|
border: 0;
|
|
701
726
|
outline: none;
|
|
702
727
|
}
|
|
703
728
|
|
|
729
|
+
/* `.multilineTextAlignment` on a view: the fields inside inherit it (texts set their own). */
|
|
730
|
+
[data-sb-text-align="leading"] {
|
|
731
|
+
text-align: left;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
[data-sb-text-align="center"] {
|
|
735
|
+
text-align: center;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
[data-sb-text-align="trailing"] {
|
|
739
|
+
text-align: right;
|
|
740
|
+
}
|
|
741
|
+
|
|
704
742
|
.sb-textfield::placeholder {
|
|
705
743
|
color: var(--sb-color-placeholder);
|
|
706
744
|
}
|
|
@@ -1386,9 +1424,10 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1386
1424
|
rounded all around, over a lighter dim. */
|
|
1387
1425
|
.sb-sheet[data-sb-floating] > .sb-sheet-card {
|
|
1388
1426
|
border-radius: 38px;
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1427
|
+
/* Liquid Glass: the content behind shows as soft blobs under a heavy blur. */
|
|
1428
|
+
background: var(--sb-sheet-glass);
|
|
1429
|
+
backdrop-filter: blur(40px) saturate(180%);
|
|
1430
|
+
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
|
1392
1431
|
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.18);
|
|
1393
1432
|
}
|
|
1394
1433
|
|
|
@@ -1832,11 +1871,9 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1832
1871
|
z-index: 6;
|
|
1833
1872
|
border-radius: 13px;
|
|
1834
1873
|
background: var(--sb-color-menu-background);
|
|
1835
|
-
backdrop-filter: blur(
|
|
1836
|
-
-webkit-backdrop-filter: blur(
|
|
1837
|
-
box-shadow:
|
|
1838
|
-
0 0 0 0.5px rgba(0, 0, 0, 0.08),
|
|
1839
|
-
0 10px 40px rgba(0, 0, 0, 0.22);
|
|
1874
|
+
backdrop-filter: blur(40px) saturate(180%);
|
|
1875
|
+
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
|
1876
|
+
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.22);
|
|
1840
1877
|
overflow: hidden;
|
|
1841
1878
|
transform-origin: 50% 0;
|
|
1842
1879
|
animation: sb-menu-in 0.18s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
|
@@ -2211,7 +2248,9 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2211
2248
|
dimmest at the top-right and bottom-left). A gradient-filled pseudo-element
|
|
2212
2249
|
the size of the surface, masked to its outer 1px (the content box cut out
|
|
2213
2250
|
of the padding box); it takes no input and sits above the surface's items,
|
|
2214
|
-
which never reach the edge.
|
|
2251
|
+
which never reach the edge. The surfaces: bar buttons and capsules, the
|
|
2252
|
+
tab bar, the search field, the sidebar toggle, menus, floating sheets,
|
|
2253
|
+
alert and dialog cards, `.glass` buttons and `.glassEffect` shapes. */
|
|
2215
2254
|
.sb-navbar-back::after,
|
|
2216
2255
|
.sb-navbar-more::after,
|
|
2217
2256
|
.sb-tabbar::after,
|
|
@@ -2219,7 +2258,14 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2219
2258
|
.sb-toolbar[data-sb-placement="trailing"]::after,
|
|
2220
2259
|
.sb-bottom-glass::after,
|
|
2221
2260
|
.sb-searchfield::after,
|
|
2222
|
-
.sb-navsplit-toggle::after
|
|
2261
|
+
.sb-navsplit-toggle::after,
|
|
2262
|
+
.sb-picker-menu::after,
|
|
2263
|
+
.sb-picker-menu-exit::after,
|
|
2264
|
+
.sb-sheet[data-sb-floating] > .sb-sheet-card::after,
|
|
2265
|
+
.sb-alert-card::after,
|
|
2266
|
+
.sb-alert-cancel-card::after,
|
|
2267
|
+
.sb-button[data-sb-style="glass"]::after,
|
|
2268
|
+
[data-sb-glass]::after {
|
|
2223
2269
|
content: "";
|
|
2224
2270
|
position: absolute;
|
|
2225
2271
|
inset: 0;
|
|
@@ -3139,8 +3185,9 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
3139
3185
|
box-sizing: border-box;
|
|
3140
3186
|
border-radius: 14px;
|
|
3141
3187
|
background: color-mix(in srgb, var(--sb-color-secondary-system-background) 85%, transparent);
|
|
3142
|
-
|
|
3143
|
-
backdrop-filter: blur(
|
|
3188
|
+
/* Liquid Glass: a heavy blur (what is behind an iPhone's alert is a formless glow). */
|
|
3189
|
+
-webkit-backdrop-filter: blur(60px) saturate(180%);
|
|
3190
|
+
backdrop-filter: blur(60px) saturate(180%);
|
|
3144
3191
|
overflow: hidden;
|
|
3145
3192
|
}
|
|
3146
3193
|
|
package/src/styles.ts
CHANGED
|
@@ -106,6 +106,8 @@ export type PaintRole = 'text' | 'paint';
|
|
|
106
106
|
export interface PaintCSS {
|
|
107
107
|
/** `color` for a flat style, `gradient` for a CSS `<image>`, `material` for a translucent fill. */
|
|
108
108
|
kind: 'color' | 'gradient' | 'material';
|
|
109
|
+
/** Liquid Glass: the element also gets the glass rim (`data-sb-glass`). */
|
|
110
|
+
glass?: boolean;
|
|
109
111
|
/**
|
|
110
112
|
* What to paint with: a `<color>` (`color`, `material`) or an `<image>`
|
|
111
113
|
* (`gradient`). Valid as a `background` shorthand value either way.
|
|
@@ -137,7 +139,7 @@ const HIERARCHICAL_PAINT: Record<1 | 2 | 3 | 4, string> = {
|
|
|
137
139
|
};
|
|
138
140
|
|
|
139
141
|
/** Opacity of the system background each material shows over the blurred backdrop. */
|
|
140
|
-
export const MATERIAL_ALPHA: Record<MaterialName, number> = {
|
|
142
|
+
export const MATERIAL_ALPHA: Record<Exclude<MaterialName, 'glass' | 'glassClear'>, number> = {
|
|
141
143
|
ultraThin: 0.55,
|
|
142
144
|
thin: 0.7,
|
|
143
145
|
regular: 0.82,
|
|
@@ -145,6 +147,15 @@ export const MATERIAL_ALPHA: Record<MaterialName, number> = {
|
|
|
145
147
|
ultraThick: 0.96,
|
|
146
148
|
};
|
|
147
149
|
export const MATERIAL_BLUR = 'blur(20px)';
|
|
150
|
+
/** Liquid Glass (`glass`, `glassClear`): the bar buttons' fill (`clear` at half of it) and blur; the renderer adds the rim. */
|
|
151
|
+
export const GLASS_BLUR = 'blur(20px) saturate(180%)';
|
|
152
|
+
|
|
153
|
+
/** The opacity a material's fill has (glass: the light fill's 70%, clear glass half of it). */
|
|
154
|
+
export function materialAlpha(name: MaterialName): number {
|
|
155
|
+
if (name === 'glass') return 0.7;
|
|
156
|
+
if (name === 'glassClear') return 0.35;
|
|
157
|
+
return MATERIAL_ALPHA[name] ?? MATERIAL_ALPHA.regular;
|
|
158
|
+
}
|
|
148
159
|
|
|
149
160
|
/**
|
|
150
161
|
* Resolve a ShapeStyle for a role. Unknown shapes resolve to magenta so they
|
|
@@ -180,8 +191,12 @@ export function styleToCSS(style: ShapeStyle, role: PaintRole): PaintCSS {
|
|
|
180
191
|
const dark = withOpacity(`color-mix(in srgb, ${base}, black 12%)`, opacity);
|
|
181
192
|
return { kind: 'gradient', value: `linear-gradient(to bottom, ${light}, ${dark})`, solid: withOpacity(base, opacity) };
|
|
182
193
|
}
|
|
194
|
+
if (isMaterialStyle(style) && (style.material === 'glass' || style.material === 'glassClear')) {
|
|
195
|
+
const value = style.material === 'glass' ? 'var(--sb-glass-background)' : 'color-mix(in srgb, var(--sb-glass-background) 50%, transparent)';
|
|
196
|
+
return { kind: 'material', value, solid: value, backdropFilter: GLASS_BLUR, glass: true };
|
|
197
|
+
}
|
|
183
198
|
if (isMaterialStyle(style)) {
|
|
184
|
-
const alpha = MATERIAL_ALPHA[style.material] ?? MATERIAL_ALPHA.regular;
|
|
199
|
+
const alpha = MATERIAL_ALPHA[style.material as keyof typeof MATERIAL_ALPHA] ?? MATERIAL_ALPHA.regular;
|
|
185
200
|
const value = `rgba(var(--sb-color-material-base), ${round(alpha * (opacity == null || !Number.isFinite(opacity) ? 1 : clamp01(opacity)))})`;
|
|
186
201
|
return { kind: 'material', value, solid: value, backdropFilter: MATERIAL_BLUR };
|
|
187
202
|
}
|
package/src/symbols.ts
CHANGED
|
@@ -421,6 +421,48 @@ import {
|
|
|
421
421
|
ZoomIn,
|
|
422
422
|
ZoomOut,
|
|
423
423
|
createElement,
|
|
424
|
+
AlignCenterHorizontal,
|
|
425
|
+
AlignCenterVertical,
|
|
426
|
+
AlignHorizontalDistributeCenter,
|
|
427
|
+
AlignStartHorizontal,
|
|
428
|
+
AlignStartVertical,
|
|
429
|
+
Box,
|
|
430
|
+
Braces,
|
|
431
|
+
CircleDashed,
|
|
432
|
+
Club,
|
|
433
|
+
Columns3,
|
|
434
|
+
CornerDownRight,
|
|
435
|
+
CornerUpLeft,
|
|
436
|
+
CornerUpRight,
|
|
437
|
+
Dice1,
|
|
438
|
+
Dice2,
|
|
439
|
+
Dice3,
|
|
440
|
+
Dice4,
|
|
441
|
+
Dice5,
|
|
442
|
+
Dice6,
|
|
443
|
+
Diff,
|
|
444
|
+
Divide,
|
|
445
|
+
Equal,
|
|
446
|
+
FastForward,
|
|
447
|
+
FlipHorizontal2,
|
|
448
|
+
FlipVertical2,
|
|
449
|
+
Layers2,
|
|
450
|
+
MemoryStick,
|
|
451
|
+
Move3d,
|
|
452
|
+
PinOff,
|
|
453
|
+
Radical,
|
|
454
|
+
Rewind,
|
|
455
|
+
Rotate3d,
|
|
456
|
+
RotateCcwSquare,
|
|
457
|
+
RotateCwSquare,
|
|
458
|
+
Scale3d,
|
|
459
|
+
Signature,
|
|
460
|
+
SlidersVertical,
|
|
461
|
+
Spade,
|
|
462
|
+
Table,
|
|
463
|
+
Tablet,
|
|
464
|
+
TextCursor,
|
|
465
|
+
Waves,
|
|
424
466
|
type IconNode,
|
|
425
467
|
} from 'lucide';
|
|
426
468
|
|
|
@@ -467,6 +509,70 @@ const SQUARE_ON_CIRCLE_FILL: IconNode = [
|
|
|
467
509
|
['rect', { x: '9', y: '9', width: '13', height: '13', rx: '3', stroke: KNOCKOUT_COLOR, 'stroke-width': '2.4' }],
|
|
468
510
|
];
|
|
469
511
|
|
|
512
|
+
/**
|
|
513
|
+
* A numeral in a circle or rounded square (`1.circle`, `1.square.fill`):
|
|
514
|
+
* lucide has none, so the numeral is drawn in its stroke style, the
|
|
515
|
+
* enclosure the size `enclosed` gives other glyphs'.
|
|
516
|
+
*/
|
|
517
|
+
const NUMERALS: Readonly<Record<string, IconNode>> = {
|
|
518
|
+
// each about 5 wide and 9 high, centered on the glyph's middle
|
|
519
|
+
'0': [['rect', { x: '9.5', y: '7.5', width: '5', height: '9', rx: '2.5' }]],
|
|
520
|
+
// a stem with a flag at its top, as lucide's `Heading1` draws its 1
|
|
521
|
+
'1': [['path', { d: 'M9.5 9.5 12.5 7.5v9' }]],
|
|
522
|
+
'2': [['path', { d: 'M9.5 9.75a2.5 2.5 0 0 1 5 0c0 1.4-.9 2.4-2 3.5L9.5 16.5h5' }]],
|
|
523
|
+
'3': [['path', { d: 'M9.5 7.5h5L12 11a2.75 2.75 0 1 1-1.94 4.69' }]],
|
|
524
|
+
'4': [['path', { d: 'M13.5 16.5v-9L9 13.5h6' }]],
|
|
525
|
+
'5': [['path', { d: 'M14.5 7.5H10l-.5 4.25c.7-.5 1.5-.75 2.5-.75a2.75 2.75 0 0 1 0 5.5c-1 0-2-.5-2.5-1.25' }]],
|
|
526
|
+
'6': [
|
|
527
|
+
['path', { d: 'M14 7.5c-2.5 1-4.5 3.5-4.5 6.5' }],
|
|
528
|
+
['circle', { cx: '12', cy: '14', r: '2.5' }],
|
|
529
|
+
],
|
|
530
|
+
'7': [['path', { d: 'M9.5 7.5h5l-3.5 9' }]],
|
|
531
|
+
'8': [
|
|
532
|
+
['circle', { cx: '12', cy: '9.5', r: '2' }],
|
|
533
|
+
['circle', { cx: '12', cy: '14', r: '2.5' }],
|
|
534
|
+
],
|
|
535
|
+
'9': [
|
|
536
|
+
['circle', { cx: '12', cy: '10', r: '2.5' }],
|
|
537
|
+
['path', { d: 'M14.5 10c0 3-2 5.5-4.5 6.5' }],
|
|
538
|
+
],
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
function numeral(digit: string, enclosure: 'circle' | 'square'): IconNode {
|
|
542
|
+
const outer: IconNode[number] =
|
|
543
|
+
enclosure === 'circle' ? ['circle', { cx: '12', cy: '12', r: '10' }] : ['rect', { x: '3', y: '3', width: '18', height: '18', rx: '4' }];
|
|
544
|
+
return [outer, ...NUMERALS[digit]!];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** `0.circle` … `9.square.fill`: every numeral, in a circle or a square, outlined or filled. */
|
|
548
|
+
const NUMERAL_SYMBOLS: Record<string, SymbolGlyph> = Object.fromEntries(
|
|
549
|
+
Object.keys(NUMERALS).flatMap((digit) =>
|
|
550
|
+
(['circle', 'square'] as const).flatMap((enclosure) => [
|
|
551
|
+
[`${digit}.${enclosure}`, { icon: numeral(digit, enclosure), fill: 'outline' }],
|
|
552
|
+
[`${digit}.${enclosure}.fill`, { icon: numeral(digit, enclosure), fill: 'knockout' }],
|
|
553
|
+
]),
|
|
554
|
+
),
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
/** `info`: the i on its own (lucide draws it only in a circle), a foot under its stem. */
|
|
558
|
+
const INFO: IconNode = [
|
|
559
|
+
['path', { d: 'M10 10h2v10' }],
|
|
560
|
+
['path', { d: 'M9.5 20h5' }],
|
|
561
|
+
['path', { d: 'M12 5h.01' }],
|
|
562
|
+
];
|
|
563
|
+
|
|
564
|
+
/** `exclamationmark`: the mark on its own. */
|
|
565
|
+
const EXCLAMATIONMARK: IconNode = [
|
|
566
|
+
['path', { d: 'M12 4v11' }],
|
|
567
|
+
['path', { d: 'M12 20h.01' }],
|
|
568
|
+
];
|
|
569
|
+
|
|
570
|
+
/** `questionmark`: lucide's `CircleQuestionMark` mark on its own, scaled up to fill the glyph. */
|
|
571
|
+
const QUESTIONMARK: IconNode = [
|
|
572
|
+
['path', { d: 'M6.91 6.53a5.25 5.25 0 0 1 10.2 1.75c0 3.5-5.25 5.25-5.25 5.25' }],
|
|
573
|
+
['path', { d: 'M12 20.5h.01' }],
|
|
574
|
+
];
|
|
575
|
+
|
|
470
576
|
const outline = (icon: IconNode): SymbolGlyph => ({ icon, fill: 'outline' });
|
|
471
577
|
const knockout = (icon: IconNode): SymbolGlyph => ({ icon, fill: 'knockout' });
|
|
472
578
|
const solid = (icon: IconNode): SymbolGlyph => ({ icon, fill: 'solid' });
|
|
@@ -491,6 +597,7 @@ export const SF_SYMBOLS: Readonly<Record<string, SymbolGlyph>> = {
|
|
|
491
597
|
'minus.circle.fill': knockout(CircleMinus),
|
|
492
598
|
'info.circle': outline(Info),
|
|
493
599
|
'info.circle.fill': knockout(Info),
|
|
600
|
+
questionmark: outline(QUESTIONMARK),
|
|
494
601
|
'questionmark.circle': outline(CircleQuestionMark),
|
|
495
602
|
'questionmark.circle.fill': knockout(CircleQuestionMark),
|
|
496
603
|
'exclamationmark.circle': outline(CircleAlert),
|
|
@@ -506,6 +613,90 @@ export const SF_SYMBOLS: Readonly<Record<string, SymbolGlyph>> = {
|
|
|
506
613
|
'circle.fill': solid(Circle),
|
|
507
614
|
'circle.inset.filled': outline(CircleDot),
|
|
508
615
|
'largecircle.fill.circle': outline(CircleDot),
|
|
616
|
+
...NUMERAL_SYMBOLS,
|
|
617
|
+
// media, keys, math, dice and cards, layout and 3D tools, the bare info and exclamation marks
|
|
618
|
+
backward: outline(Rewind),
|
|
619
|
+
'backward.fill': solid(Rewind),
|
|
620
|
+
forward: outline(FastForward),
|
|
621
|
+
'forward.fill': solid(FastForward),
|
|
622
|
+
goforward: outline(RotateCw),
|
|
623
|
+
gobackward: outline(RotateCcw),
|
|
624
|
+
cube: outline(Box),
|
|
625
|
+
'cube.fill': outline(Box),
|
|
626
|
+
shift: outline(ArrowBigUp),
|
|
627
|
+
'shift.fill': solid(ArrowBigUp),
|
|
628
|
+
control: outline(ChevronUp),
|
|
629
|
+
curlybraces: outline(Braces),
|
|
630
|
+
equal: outline(Equal),
|
|
631
|
+
divide: outline(Divide),
|
|
632
|
+
multiply: outline(X),
|
|
633
|
+
plusminus: outline(Diff),
|
|
634
|
+
'x.squareroot': outline(Radical),
|
|
635
|
+
'die.face.1': outline(Dice1),
|
|
636
|
+
'die.face.2': outline(Dice2),
|
|
637
|
+
'die.face.3': outline(Dice3),
|
|
638
|
+
'die.face.4': outline(Dice4),
|
|
639
|
+
'die.face.5': outline(Dice5),
|
|
640
|
+
'die.face.6': outline(Dice6),
|
|
641
|
+
'die.face.1.fill': knockout(Dice1),
|
|
642
|
+
'die.face.2.fill': knockout(Dice2),
|
|
643
|
+
'die.face.3.fill': knockout(Dice3),
|
|
644
|
+
'die.face.4.fill': knockout(Dice4),
|
|
645
|
+
'die.face.5.fill': knockout(Dice5),
|
|
646
|
+
'die.face.6.fill': knockout(Dice6),
|
|
647
|
+
display: outline(Monitor),
|
|
648
|
+
ipad: outline(Tablet),
|
|
649
|
+
internaldrive: outline(HardDrive),
|
|
650
|
+
memorychip: outline(MemoryStick),
|
|
651
|
+
// lucide names its flips by the axis line: its FlipVertical2 mirrors left to right
|
|
652
|
+
'flip.horizontal': outline(FlipVertical2),
|
|
653
|
+
'flip.vertical': outline(FlipHorizontal2),
|
|
654
|
+
'rotate.left': outline(RotateCcwSquare),
|
|
655
|
+
'rotate.right': outline(RotateCwSquare),
|
|
656
|
+
'align.horizontal.left': outline(AlignStartVertical),
|
|
657
|
+
'align.horizontal.center': outline(AlignCenterVertical),
|
|
658
|
+
'align.vertical.top': outline(AlignStartHorizontal),
|
|
659
|
+
'align.vertical.center': outline(AlignCenterHorizontal),
|
|
660
|
+
'distribute.horizontal.center': outline(AlignHorizontalDistributeCenter),
|
|
661
|
+
'move.3d': outline(Move3d),
|
|
662
|
+
'rotate.3d': outline(Rotate3d),
|
|
663
|
+
'scale.3d': outline(Scale3d),
|
|
664
|
+
'square.2.layers.3d': outline(Layers2),
|
|
665
|
+
'square.3.layers.3d': outline(Layers3),
|
|
666
|
+
'square.3.stack.3d': outline(Layers),
|
|
667
|
+
'hand.point.up.left': outline(Pointer),
|
|
668
|
+
'pin.slash': outline(PinOff),
|
|
669
|
+
'square.dotted': outline(SquareDashed),
|
|
670
|
+
'rectangle.dashed': outline(SquareDashed),
|
|
671
|
+
'circle.dashed': outline(CircleDashed),
|
|
672
|
+
'rectangle.split.3x1': outline(Columns3),
|
|
673
|
+
tablecells: outline(Table),
|
|
674
|
+
'tablecells.fill': outline(Table),
|
|
675
|
+
signature: outline(Signature),
|
|
676
|
+
'sidebar.leading': outline(PanelLeft),
|
|
677
|
+
'sidebar.trailing': outline(PanelRight),
|
|
678
|
+
'slider.vertical.3': outline(SlidersVertical),
|
|
679
|
+
'suit.heart': outline(Heart),
|
|
680
|
+
'suit.heart.fill': solid(Heart),
|
|
681
|
+
'suit.club': outline(Club),
|
|
682
|
+
'suit.club.fill': solid(Club),
|
|
683
|
+
'suit.spade': outline(Spade),
|
|
684
|
+
'suit.spade.fill': solid(Spade),
|
|
685
|
+
'suit.diamond': outline(Diamond),
|
|
686
|
+
'suit.diamond.fill': solid(Diamond),
|
|
687
|
+
target: outline(Target),
|
|
688
|
+
'water.waves': outline(Waves),
|
|
689
|
+
'text.cursor': outline(TextCursor),
|
|
690
|
+
'arrow.turn.down.left': outline(CornerDownLeft),
|
|
691
|
+
'arrow.turn.down.right': outline(CornerDownRight),
|
|
692
|
+
'arrow.turn.up.left': outline(CornerUpLeft),
|
|
693
|
+
'arrow.turn.up.right': outline(CornerUpRight),
|
|
694
|
+
'chevron.compact.down': outline(ChevronDown),
|
|
695
|
+
'chevron.compact.up': outline(ChevronUp),
|
|
696
|
+
'chevron.compact.left': outline(ChevronLeft),
|
|
697
|
+
'chevron.compact.right': outline(ChevronRight),
|
|
698
|
+
info: outline(INFO),
|
|
699
|
+
exclamationmark: outline(EXCLAMATIONMARK),
|
|
509
700
|
square: outline(Square),
|
|
510
701
|
'square.fill': solid(Square),
|
|
511
702
|
star: outline(Star),
|