@remit/ui 0.0.88 → 0.0.90

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.88",
3
+ "version": "0.0.90",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -259,7 +259,7 @@ export function AppShellSlotted({
259
259
  <AppShellLayoutCtx.Provider value={layoutCtx}>
260
260
  <div
261
261
  ref={containerRef}
262
- className="@container flex h-dvh w-full flex-col overflow-hidden bg-canvas font-sans text-fg"
262
+ className="safe-area-frame @container flex h-dvh w-full flex-col overflow-hidden bg-canvas font-sans text-fg"
263
263
  >
264
264
  {isLoading && skeleton ? (
265
265
  skeleton
@@ -124,7 +124,15 @@ export function useContainerWidth(
124
124
  if (entry) setWidth(entry.contentRect.width);
125
125
  });
126
126
  observer.observe(el);
127
- setWidth(el.getBoundingClientRect().width);
127
+ // The content box, matching what the observer reports above: the shell
128
+ // root carries the device safe-area insets as padding and the panes are
129
+ // laid out in what is left, so the first synchronous measure and every
130
+ // later one have to be the same box.
131
+ const style = getComputedStyle(el);
132
+ const horizontalPadding =
133
+ Number.parseFloat(style.paddingLeft) +
134
+ Number.parseFloat(style.paddingRight);
135
+ setWidth(el.clientWidth - horizontalPadding);
128
136
  return () => observer.disconnect();
129
137
  }, []);
130
138
  return [ref, width];
@@ -18,7 +18,7 @@ export function AuthCard({ children, className }: AuthCardProps) {
18
18
  <div
19
19
  data-auth-page
20
20
  className={cn(
21
- "flex min-h-dvh w-full flex-col items-center justify-center overflow-y-auto bg-surface-sunken px-4 py-8 text-fg",
21
+ "flex min-h-dvh w-full flex-col items-center justify-center overflow-y-auto bg-surface-sunken pb-[max(2rem,env(safe-area-inset-bottom,0px))] pl-[max(1rem,env(safe-area-inset-left,0px))] pr-[max(1rem,env(safe-area-inset-right,0px))] pt-[max(2rem,env(safe-area-inset-top,0px))] text-fg",
22
22
  className,
23
23
  )}
24
24
  style={{
@@ -119,7 +119,7 @@ export function BottomSheet({
119
119
  />
120
120
  <div
121
121
  ref={sheetRef}
122
- className="absolute inset-x-0 bottom-0 flex max-h-[92%] flex-col rounded-t-3xl border-t border-line bg-surface shadow-2xl shadow-black/30"
122
+ className="absolute inset-x-0 bottom-0 flex max-h-[92%] flex-col rounded-t-3xl border-t border-line bg-surface pb-[env(safe-area-inset-bottom,0px)] shadow-2xl shadow-black/30"
123
123
  style={{ transform: `translateY(${offset}px)`, transition }}
124
124
  >
125
125
  <div
@@ -85,10 +85,12 @@ export function Dialog({
85
85
  aria-labelledby="dialog-title"
86
86
  className={cn(
87
87
  "relative z-10 overflow-hidden border-line bg-surface shadow-xl",
88
+ // The edge-anchored panels reach the device edges; the centered
89
+ // card floats clear of them.
88
90
  isLeft
89
- ? "h-full w-72 max-w-[85vw] border-r"
91
+ ? "safe-area-frame h-full w-72 max-w-[85vw] border-r"
90
92
  : isRight
91
- ? "h-full w-[80vw] max-w-[320px] border-l"
93
+ ? "safe-area-frame h-full w-[80vw] max-w-[320px] border-l"
92
94
  : "w-full max-w-lg rounded-md border",
93
95
  className,
94
96
  )}
@@ -4,7 +4,16 @@ import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
5
  import { MobileMessageActionBar } from "./mobile-message-action-bar.js";
6
6
 
7
- const base = { hasThread: true } as const;
7
+ // A wired host, as both real callers are: the bar offers only the verbs it was
8
+ // given an answer for.
9
+ const base = {
10
+ hasThread: true,
11
+ onReply: () => undefined,
12
+ onReplyAll: () => undefined,
13
+ onForward: () => undefined,
14
+ onToggleStar: () => undefined,
15
+ onDelete: () => undefined,
16
+ } as const;
8
17
 
9
18
  const countMatches = (html: string, needle: string) =>
10
19
  html.split(needle).length - 1;
@@ -64,17 +73,57 @@ describe("MobileMessageActionBar", () => {
64
73
 
65
74
  it("renders the per-message reply verbs and no intelligence toggle", () => {
66
75
  const html = renderToString(createElement(MobileMessageActionBar, base));
67
- assert.match(html, /aria-label="Reply"/);
68
- assert.match(html, /aria-label="Reply all"/);
69
- assert.match(html, /aria-label="Forward"/);
76
+ assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
77
+ assert.equal(countMatches(html, 'aria-label="Reply all"'), 1);
78
+ assert.equal(countMatches(html, 'aria-label="Forward"'), 1);
70
79
  assert.doesNotMatch(html, /intelligence panel/);
71
80
  });
72
81
 
73
- it("keeps verbs pressable rather than disabling them", () => {
82
+ it("drops a verb the host supplies no handler for", () => {
83
+ const html = renderToString(
84
+ createElement(MobileMessageActionBar, {
85
+ hasThread: true,
86
+ onReply: () => undefined,
87
+ }),
88
+ );
89
+ assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
90
+ assert.doesNotMatch(html, /aria-label="Reply all"/);
91
+ assert.doesNotMatch(html, /aria-label="Forward"/);
92
+ });
93
+
94
+ it("never disables a verb it offers", () => {
74
95
  const html = renderToString(createElement(MobileMessageActionBar, base));
96
+ assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
75
97
  assert.doesNotMatch(html, /disabled=""/);
76
98
  });
77
99
 
100
+ it("keeps the host's verbs pressable with no message open", () => {
101
+ const html = renderToString(
102
+ createElement(MobileMessageActionBar, {
103
+ hasThread: false,
104
+ onReply: () => undefined,
105
+ onReplyAll: () => undefined,
106
+ onForward: () => undefined,
107
+ }),
108
+ );
109
+ assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
110
+ assert.equal(countMatches(html, 'aria-label="Reply all"'), 1);
111
+ assert.equal(countMatches(html, 'aria-label="Forward"'), 1);
112
+ assert.doesNotMatch(html, /disabled=""/);
113
+ });
114
+
115
+ it("drops an unhandled verb with no message open too", () => {
116
+ const html = renderToString(
117
+ createElement(MobileMessageActionBar, {
118
+ hasThread: false,
119
+ onReply: () => undefined,
120
+ }),
121
+ );
122
+ assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
123
+ assert.doesNotMatch(html, /aria-label="Reply all"/);
124
+ assert.doesNotMatch(html, /aria-label="Forward"/);
125
+ });
126
+
78
127
  it("surfaces the unavailable hint when no message is open", () => {
79
128
  const html = renderToString(
80
129
  createElement(MobileMessageActionBar, {
@@ -37,6 +37,7 @@ const meta: Meta<typeof MobileMessageActionBar> = {
37
37
  onToggleStar: () => undefined,
38
38
  onDelete: () => undefined,
39
39
  onToggleRead: () => undefined,
40
+ onUnavailable: () => undefined,
40
41
  },
41
42
  };
42
43
  export default meta;
@@ -47,6 +48,12 @@ export const Default: Story = {};
47
48
 
48
49
  export const Starred: Story = { args: { isStarred: true } };
49
50
 
51
+ /** A host that cannot answer a verb omits its handler and the bar drops the
52
+ * button, rather than offering one that reacts to nothing. */
53
+ export const WithoutForward: Story = {
54
+ args: { onForward: undefined },
55
+ };
56
+
50
57
  /** No message open: the verbs no-op and the bar surfaces a one-line reason
51
58
  * instead of disabling. */
52
59
  export const NoMessageOpen: Story = {
@@ -55,3 +62,14 @@ export const NoMessageOpen: Story = {
55
62
  unavailableHint: "Open a message first",
56
63
  },
57
64
  };
65
+
66
+ /** The two rules together: the host owns reply but not forward, and no message
67
+ * is open. Reply stays up and explains itself on press; forward is not there
68
+ * to press. Whether a message is open never decides which verbs exist. */
69
+ export const NoMessageOpenWithoutForward: Story = {
70
+ args: {
71
+ hasThread: false,
72
+ unavailableHint: "Open a message first",
73
+ onForward: undefined,
74
+ },
75
+ };
@@ -24,8 +24,7 @@ export interface MobileMessageActionBarProps {
24
24
  /** Whether a message is open. Gates the verbs (never disables). */
25
25
  hasThread: boolean;
26
26
  /* ---- Reply verbs ----
27
- * Per-message: replying targets the message this bar belongs to. Omit a
28
- * handler to drop that verb. */
27
+ * Per-message: replying targets the message this bar belongs to. */
29
28
  onReply?: () => void;
30
29
  onReplyAll?: () => void;
31
30
  onForward?: () => void;
@@ -59,8 +58,13 @@ const TOUCH = "min-h-11 min-w-11 px-0";
59
58
  * repeats it. Intelligence is not here: it lives once in the top app bar and
60
59
  * reflects the active message. No archive either — Remit is IMAP-backed and
61
60
  * IMAP has no archive concept. Built from the kit `Button`, `PopoverMenu` and a
62
- * caller-supplied `moveSlot`; buttons stay pressable with no message open and
63
- * call `onUnavailable` rather than greying out.
61
+ * caller-supplied `moveSlot`.
62
+ *
63
+ * One rule for what the bar shows, both halves of it: a verb the host has left
64
+ * unhandled is not part of this surface and is not offered, and a verb the host
65
+ * owns stays pressable with no message open and explains itself through
66
+ * `onUnavailable` rather than greying out. What the bar never does is render a
67
+ * control that answers nothing.
64
68
  */
65
69
  export function MobileMessageActionBar({
66
70
  hasThread,
@@ -104,58 +108,71 @@ export function MobileMessageActionBar({
104
108
  return (
105
109
  <div className={cn("relative", className)}>
106
110
  <div className="flex h-12 shrink-0 items-center gap-0.5 bg-canvas px-1">
107
- <Button
108
- variant="ghost"
109
- size="sm"
110
- icon={<Reply className="size-5" />}
111
- onClick={act("reply", onReply)}
112
- aria-label="Reply"
113
- title="Reply"
114
- className={TOUCH}
115
- />
116
- <Button
117
- variant="ghost"
118
- size="sm"
119
- icon={<ReplyAll className="size-5" />}
120
- onClick={act("replyAll", onReplyAll)}
121
- aria-label="Reply all"
122
- title="Reply all"
123
- className={TOUCH}
124
- />
125
- <Button
126
- variant="ghost"
127
- size="sm"
128
- icon={<Forward className="size-5" />}
129
- onClick={act("forward", onForward)}
130
- aria-label="Forward"
131
- title="Forward"
132
- className={TOUCH}
133
- />
111
+ {onReply && (
112
+ <Button
113
+ variant="ghost"
114
+ size="sm"
115
+ icon={<Reply className="size-5" />}
116
+ onClick={act("reply", onReply)}
117
+ aria-label="Reply"
118
+ title="Reply"
119
+ className={TOUCH}
120
+ />
121
+ )}
122
+ {onReplyAll && (
123
+ <Button
124
+ variant="ghost"
125
+ size="sm"
126
+ icon={<ReplyAll className="size-5" />}
127
+ onClick={act("replyAll", onReplyAll)}
128
+ aria-label="Reply all"
129
+ title="Reply all"
130
+ className={TOUCH}
131
+ />
132
+ )}
133
+ {onForward && (
134
+ <Button
135
+ variant="ghost"
136
+ size="sm"
137
+ icon={<Forward className="size-5" />}
138
+ onClick={act("forward", onForward)}
139
+ aria-label="Forward"
140
+ title="Forward"
141
+ className={TOUCH}
142
+ />
143
+ )}
134
144
  <div className="flex-1" />
135
- <Button
136
- variant="ghost"
137
- size="sm"
138
- icon={
139
- <Star
140
- className={cn("size-5", isStarred && "fill-warning text-warning")}
141
- />
142
- }
143
- onClick={act("star", onToggleStar)}
144
- aria-label={isStarred ? "Unstar" : "Star"}
145
- aria-pressed={isStarred}
146
- title={isStarred ? "Unstar" : "Star"}
147
- className={TOUCH}
148
- />
145
+ {onToggleStar && (
146
+ <Button
147
+ variant="ghost"
148
+ size="sm"
149
+ icon={
150
+ <Star
151
+ className={cn(
152
+ "size-5",
153
+ isStarred && "fill-warning text-warning",
154
+ )}
155
+ />
156
+ }
157
+ onClick={act("star", onToggleStar)}
158
+ aria-label={isStarred ? "Unstar" : "Star"}
159
+ aria-pressed={isStarred}
160
+ title={isStarred ? "Unstar" : "Star"}
161
+ className={TOUCH}
162
+ />
163
+ )}
149
164
  {moveSlot}
150
- <Button
151
- variant="ghost"
152
- size="sm"
153
- icon={<Trash2 className="size-5" />}
154
- onClick={act("delete", onDelete)}
155
- aria-label="Move to Trash"
156
- title="Move to Trash"
157
- className={TOUCH}
158
- />
165
+ {onDelete && (
166
+ <Button
167
+ variant="ghost"
168
+ size="sm"
169
+ icon={<Trash2 className="size-5" />}
170
+ onClick={act("delete", onDelete)}
171
+ aria-label="Move to Trash"
172
+ title="Move to Trash"
173
+ className={TOUCH}
174
+ />
175
+ )}
159
176
  <PopoverMenu
160
177
  triggerLabel="More actions"
161
178
  items={[...readItem, ...overflowItems]}
@@ -82,7 +82,16 @@ describe("MobileReadingPane", () => {
82
82
 
83
83
  it("shows exactly one per-message action bar — the expanded message's", () => {
84
84
  const html = renderToString(
85
- createElement(MobileReadingPane, { thread, onBack: () => undefined }),
85
+ createElement(MobileReadingPane, {
86
+ thread,
87
+ onBack: () => undefined,
88
+ // A wired host, as the app is: an unhandled verb is not offered.
89
+ actions: {
90
+ onReply: () => undefined,
91
+ onToggleStar: () => undefined,
92
+ onDelete: () => undefined,
93
+ },
94
+ }),
86
95
  );
87
96
  // One expanded message ⇒ one Reply / Flag / Trash cluster; the two
88
97
  // collapsed rows carry no bar.
@@ -95,8 +104,13 @@ describe("MobileReadingPane", () => {
95
104
  // The desktop/legacy footer carries a "Reply (r)" title; the per-message
96
105
  // bar titles are plain "Reply". Assert the footer affordance is absent.
97
106
  const html = renderToString(
98
- createElement(MobileReadingPane, { thread, onBack: () => undefined }),
107
+ createElement(MobileReadingPane, {
108
+ thread,
109
+ onBack: () => undefined,
110
+ actions: { onReply: () => undefined },
111
+ }),
99
112
  );
113
+ assert.match(html, /title="Reply"/);
100
114
  assert.doesNotMatch(html, /title="Reply \(r\)"/);
101
115
  });
102
116
  });
@@ -11,6 +11,10 @@ import {
11
11
  import { MobileMessageActionBar } from "./mobile-message-action-bar.js";
12
12
  import { CollapsedMessage, ExpandedMessage } from "./reading-pane.js";
13
13
 
14
+ /**
15
+ * What the host answers for a message. A verb left out is not offered on the
16
+ * message's action bar rather than rendered against nothing.
17
+ */
14
18
  export interface MobileReadingMessageActions {
15
19
  /** Expand / collapse a message row. */
16
20
  onToggleExpand?: (id: string) => void;
@@ -1,6 +1,7 @@
1
1
  import type { ReactElement } from "react";
2
2
  import { useEffect, useState } from "react";
3
3
  import ReactPullToRefresh from "react-simple-pull-to-refresh";
4
+ import { DESKTOP_MEDIA_QUERY } from "../lib/layout-breakpoints.js";
4
5
 
5
6
  export interface PullToRefreshProps {
6
7
  children: ReactElement;
@@ -28,9 +29,9 @@ const useMatchMedia = (query: string): boolean => {
28
29
 
29
30
  /**
30
31
  * Wraps a scrollable list with a pull-to-refresh gesture on mobile. Below the
31
- * desktop breakpoint (Tailwind `lg`, 1024px) a downward pull at the top of the
32
- * list fires `onRefresh`; at desktop widths the gesture is inert and children
33
- * render directly, since there is no touch list to pull.
32
+ * desktop breakpoint (Tailwind `lg`) a downward pull at the top of the list
33
+ * fires `onRefresh`; at desktop the gesture is inert and children render
34
+ * directly, since there is no touch list to pull.
34
35
  *
35
36
  * Presentational: the caller owns what refreshing means via `onRefresh` and
36
37
  * surfaces in-flight state through `isRefreshing`.
@@ -40,7 +41,7 @@ export const PullToRefresh = ({
40
41
  onRefresh,
41
42
  isRefreshing,
42
43
  }: PullToRefreshProps): ReactElement => {
43
- const isDesktop = useMatchMedia("(min-width: 1024px)");
44
+ const isDesktop = useMatchMedia(DESKTOP_MEDIA_QUERY);
44
45
 
45
46
  if (isDesktop) {
46
47
  return children;
@@ -148,7 +148,7 @@ export function WizardScreen({
148
148
  className="fixed inset-0 z-50 flex flex-col font-sans text-fg md:items-center md:justify-center md:bg-black/40 md:p-6"
149
149
  >
150
150
  <div className="flex min-h-0 w-full flex-1 flex-col bg-canvas md:h-[45rem] md:max-h-[calc(100dvh-3rem)] md:w-[35rem] md:max-w-[calc(100vw-3rem)] md:flex-none md:overflow-hidden md:rounded-xl md:border md:border-line md:shadow-lg">
151
- <header className="shrink-0 border-b border-line px-3 pb-2 pt-3">
151
+ <header className="shrink-0 border-b border-line px-3 pb-2 pt-[calc(0.75rem+env(safe-area-inset-top,0px))] md:pt-3">
152
152
  <div className="flex items-center gap-1">
153
153
  <button
154
154
  type="button"
@@ -102,7 +102,7 @@ export function SettingsShell({
102
102
  );
103
103
 
104
104
  return (
105
- <div className="flex h-dvh w-full overflow-hidden bg-canvas font-sans text-fg">
105
+ <div className="safe-area-frame flex h-dvh w-full overflow-hidden bg-canvas font-sans text-fg">
106
106
  {/* Desktop nav rail */}
107
107
  <aside className="hidden w-60 shrink-0 flex-col border-r border-line bg-surface-sunken lg:flex">
108
108
  <div className="flex h-pane-header shrink-0 items-center border-b border-line px-2">
@@ -127,7 +127,7 @@ export function SettingsShell({
127
127
  className="absolute inset-0 bg-canvas/60"
128
128
  onClick={() => setNavOpen(false)}
129
129
  />
130
- <aside className="relative flex w-72 max-w-[85%] flex-col border-r border-line bg-surface-sunken">
130
+ <aside className="safe-area-frame relative flex w-72 max-w-[85%] flex-col border-r border-line bg-surface-sunken">
131
131
  <div className="flex h-pane-header shrink-0 items-center justify-between border-b border-line px-2">
132
132
  <button
133
133
  type="button"
@@ -156,6 +156,19 @@ export function SettingsShell({
156
156
  <main className="flex min-w-0 flex-1 flex-col">
157
157
  {/* pane-header datum: title row, hairline at the shared y */}
158
158
  <header className="flex h-pane-header shrink-0 items-center gap-3 border-b border-line px-3 lg:px-5">
159
+ {/* Below desktop this is the only way out of settings that is on
160
+ screen: the rail carrying "Back to mail" is a drawer here, and an
161
+ app launched from a home screen has no browser back button. */}
162
+ {onBackToMail && (
163
+ <Button
164
+ variant="ghost"
165
+ size="sm"
166
+ icon={<ChevronLeft className="size-4" />}
167
+ className="-ml-1 lg:hidden"
168
+ onClick={onBackToMail}
169
+ aria-label="Back to mail"
170
+ />
171
+ )}
159
172
  <Button
160
173
  variant="ghost"
161
174
  size="sm"
@@ -54,7 +54,7 @@ export function SlidePanel({
54
54
 
55
55
  <div
56
56
  className={cn(
57
- "fixed top-0 right-0 z-50 h-full w-full border-l border-line bg-canvas shadow-xl sm:w-[400px] sm:max-w-[90vw]",
57
+ "safe-area-frame fixed top-0 right-0 z-50 h-full w-full border-l border-line bg-canvas shadow-xl sm:w-[400px] sm:max-w-[90vw]",
58
58
  "transform transition-transform duration-200 ease-out",
59
59
  isOpen ? "translate-x-0" : "pointer-events-none translate-x-full",
60
60
  )}
@@ -81,7 +81,7 @@ export function WizardShell({
81
81
  // (optical center) on larger screens. On phone the top padding is
82
82
  // minimal so taller steps (connector picker, servers) don't push the
83
83
  // CTA bar below the fold — the shell scrolls if content overflows.
84
- <div className="flex min-h-dvh w-full flex-col items-center overflow-y-auto bg-canvas px-8 pb-8 pt-8 font-sans text-fg sm:pt-[30vh]">
84
+ <div className="flex min-h-dvh w-full flex-col items-center overflow-y-auto bg-canvas pb-[max(2rem,env(safe-area-inset-bottom,0px))] pl-[max(2rem,env(safe-area-inset-left,0px))] pr-[max(2rem,env(safe-area-inset-right,0px))] pt-[max(2rem,env(safe-area-inset-top,0px))] font-sans text-fg sm:pt-[30vh]">
85
85
  {!hideSteps && (
86
86
  <div className="mb-5 w-full max-w-xl">
87
87
  <StepRail steps={steps} activeStep={activeStep} />
package/src/index.ts CHANGED
@@ -699,6 +699,10 @@ export {
699
699
  labelColorOptions,
700
700
  labelDotClass,
701
701
  } from "./lib/label-color.js";
702
+ export {
703
+ DESKTOP_MEDIA_QUERY,
704
+ DESKTOP_MIN_WIDTH,
705
+ } from "./lib/layout-breakpoints.js";
702
706
  export {
703
707
  derivePropertyClauses,
704
708
  normalizeSubject,
@@ -0,0 +1,12 @@
1
+ export const DESKTOP_MIN_WIDTH = 1024;
2
+
3
+ /**
4
+ * The desktop gate. Width alone is not enough: a large tablet in portrait is
5
+ * exactly 1024px wide, and the multi-pane shell does not fit a surface held
6
+ * upright. A coarse pointer in portrait therefore never counts as desktop,
7
+ * while a tall monitor (fine pointer) still does.
8
+ *
9
+ * `tokens.css` redefines Tailwind's `lg` variant with this exact condition,
10
+ * so the CSS-gated chrome and anything reading the query in JS flip together.
11
+ */
12
+ export const DESKTOP_MEDIA_QUERY = `(min-width: ${DESKTOP_MIN_WIDTH}px) and (not ((orientation: portrait) and (pointer: coarse)))`;
package/src/tokens.css CHANGED
@@ -11,6 +11,19 @@
11
11
  .dark on a wrapper; an app would toggle it on <html>. */
12
12
  @custom-variant dark (&:is(.dark *));
13
13
 
14
+ /* Tailwind's built-in lg variant, redefined: desktop chrome is a device
15
+ posture, not a width. A large tablet is exactly 1024px wide in portrait
16
+ and belongs in the single-pane layout, so a coarse pointer held upright
17
+ never reaches lg regardless of width. A tall desktop monitor has a fine
18
+ pointer and keeps it. Every lg: class in the app and the workbench rides
19
+ this rule; it must stay identical to DESKTOP_MEDIA_QUERY in
20
+ src/lib/layout-breakpoints.ts. */
21
+ @custom-variant lg {
22
+ @media (min-width: 1024px) and (not ((orientation: portrait) and (pointer: coarse))) {
23
+ @slot;
24
+ }
25
+ }
26
+
14
27
  /* @theme inline aliases Tailwind utilities (bg-surface, text-fg, etc.)
15
28
  to the semantic CSS variables declared on :root / .dark below. The
16
29
  :root / .dark blocks MUST come after this @theme block — otherwise
@@ -193,6 +206,19 @@ html.dark,
193
206
  color-scheme: dark;
194
207
  }
195
208
 
209
+ /* Installed to a home screen the app runs without browser chrome, so its own
210
+ surfaces reach the status bar, the home indicator and, in landscape, the
211
+ notch. A screen-owning root carries the device insets; `viewport-fit=cover`
212
+ in the page head is what makes them anything but zero, and in a browser tab
213
+ they stay zero. Sized in the border box, so a root pinned to the viewport
214
+ keeps its height and gives the room out of its content. */
215
+ @utility safe-area-frame {
216
+ padding-top: env(safe-area-inset-top, 0px);
217
+ padding-right: env(safe-area-inset-right, 0px);
218
+ padding-bottom: env(safe-area-inset-bottom, 0px);
219
+ padding-left: env(safe-area-inset-left, 0px);
220
+ }
221
+
196
222
  body {
197
223
  margin: 0;
198
224
  background-color: var(--color-canvas);