@tribe-nest/forge 3.42.1 → 3.50.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.
Files changed (49) hide show
  1. package/package.json +2 -2
  2. package/src/data/queries/useCallChat.ts +162 -0
  3. package/src/data/queries/useCallRecording.ts +80 -0
  4. package/src/data/queries/useMembershipCalls.ts +212 -0
  5. package/src/i18n/de.json +74 -1
  6. package/src/i18n/en.json +74 -1
  7. package/src/index.ts +7 -0
  8. package/src/types/models.ts +58 -5
  9. package/src/ui/headless/memberHome/index.ts +4 -0
  10. package/src/ui/headless/memberHome/useMemberCalls.ts +150 -0
  11. package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
  12. package/src/ui/index.ts +3 -0
  13. package/src/ui/media/CallStage.tsx +691 -553
  14. package/src/ui/media/PreJoin.tsx +269 -0
  15. package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
  16. package/src/ui/media/_tests/callLayout.spec.ts +182 -0
  17. package/src/ui/media/_tests/callSignals.spec.ts +87 -0
  18. package/src/ui/media/_tests/callState.spec.ts +93 -1
  19. package/src/ui/media/_tests/fakeNode.ts +4 -1
  20. package/src/ui/media/callChat.ts +33 -0
  21. package/src/ui/media/callState.ts +125 -9
  22. package/src/ui/media/hooks/useCallSignals.ts +135 -0
  23. package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
  24. package/src/ui/media/index.ts +41 -1
  25. package/src/ui/media/panels/CallChatPanel.tsx +200 -0
  26. package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
  27. package/src/ui/media/panels/CallSidePanel.tsx +152 -0
  28. package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
  29. package/src/ui/media/stage/CallControlsBar.tsx +547 -0
  30. package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
  31. package/src/ui/media/stage/CallToasts.tsx +49 -0
  32. package/src/ui/media/stage/CallTopBar.tsx +257 -0
  33. package/src/ui/media/stage/DeviceMenu.tsx +142 -0
  34. package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
  35. package/src/ui/media/stage/SelfView.tsx +146 -0
  36. package/src/ui/media/stage/VideoTile.tsx +232 -0
  37. package/src/ui/media/stage/callLayout.ts +253 -0
  38. package/src/ui/media/stage/callSignals.ts +91 -0
  39. package/src/ui/media/stage/callSounds.ts +78 -0
  40. package/src/ui/media/stage/stageTheme.ts +50 -0
  41. package/src/ui/styled/CohortPage.tsx +15 -1
  42. package/src/ui/styled/CourseAccess.tsx +19 -6
  43. package/src/ui/styled/CourseCallsSection.tsx +99 -0
  44. package/src/ui/styled/FormRenderer.tsx +14 -4
  45. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
  46. package/src/ui/styled/forge-utilities.css +13 -0
  47. package/src/ui/styled/members/MemberCallsCalendar.tsx +589 -0
  48. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
  49. package/src/ui/styled/members/index.ts +1 -0
@@ -0,0 +1,50 @@
1
+ import type { ResolvedThemeTokens } from "../../theme/ForgeThemeProvider";
2
+
3
+ /**
4
+ * The call stage's palette: a dark stage, with the site's accents.
5
+ *
6
+ * THE one sanctioned exception to "colors only from theme tokens" (a locked
7
+ * product decision): the call screen commits to a near-black chrome on every
8
+ * surface, the way every video product does, because faces read against dark
9
+ * and a light-themed site would otherwise produce a white call screen. The
10
+ * neutral constants live in THIS file and nowhere else; every accent (the
11
+ * speaking ring, active buttons, badges, danger) still flows from the theme,
12
+ * so the stage is recognisably the site's.
13
+ */
14
+ export type StageTokens = {
15
+ /** Near-black stage floor. */
16
+ stageBg: string;
17
+ /** Tiles, panels, dialogs: one step up from the floor. */
18
+ stageSurface: string;
19
+ /** The control bar and top bar: one more step. */
20
+ stageChrome: string;
21
+ stageText: string;
22
+ stageMuted: string;
23
+ stageBorder: string;
24
+ /** The site's primary: speaking ring, active controls, badges. */
25
+ accent: string;
26
+ /** Text on the accent. */
27
+ accentText: string;
28
+ /** The site's alarming color: leave, recording dot, errors. */
29
+ danger: string;
30
+ cornerRadius: number;
31
+ fontFamily?: string | undefined;
32
+ headingFontFamily?: string | undefined;
33
+ };
34
+
35
+ export function stageTokens(tokens: ResolvedThemeTokens): StageTokens {
36
+ return {
37
+ stageBg: "#0c0d10",
38
+ stageSurface: "#17181d",
39
+ stageChrome: "#1e2026",
40
+ stageText: "#f4f5f7",
41
+ stageMuted: "#9aa0ab",
42
+ stageBorder: "#2c2f37",
43
+ accent: tokens.primary,
44
+ accentText: tokens.textPrimary,
45
+ danger: tokens.secondary,
46
+ cornerRadius: tokens.cornerRadius,
47
+ fontFamily: tokens.fontFamily,
48
+ headingFontFamily: tokens.headingFontFamily,
49
+ };
50
+ }
@@ -1,6 +1,7 @@
1
1
  import { useCohortPage } from "../../data/queries/useCohortPage";
2
2
  import { useForgeT } from "../../i18n";
3
3
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+ import { CourseCallsSection } from "./CourseCallsSection";
4
5
  import { Loading } from "./Loading";
5
6
 
6
7
  export interface CohortPageProps {
@@ -13,7 +14,7 @@ function formatDate(value: string | number | Date): string {
13
14
  return new Date(value).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
14
15
  }
15
16
 
16
- /** Cohort landing page — structured cohort/course/curriculum data. Built on `useCohortPage`. */
17
+ /** Cohort landing page: structured cohort/course/curriculum data. Built on `useCohortPage`. */
17
18
  export function CohortPage({ cohortId }: CohortPageProps) {
18
19
  const theme = useForgeTheme();
19
20
  const t = useForgeT();
@@ -79,6 +80,19 @@ export function CohortPage({ cohortId }: CohortPageProps) {
79
80
  </div>
80
81
  </section>
81
82
  )}
83
+
84
+ {/* The cohort's live calls, joinable, for the student looking at them.
85
+ The structured `data.calls` above this page carries no join address
86
+ on purpose (the payload is anonymous), so the JOINABLE list is read
87
+ separately and only for a signed-in student. Scoped to this cohort
88
+ plus the course behind it: a session addressed to everyone who bought
89
+ the course belongs on a cohort student's schedule, a sibling cohort's
90
+ does not. */}
91
+ <CourseCallsSection
92
+ courseId={course?.id ?? null}
93
+ cohortId={cohort.id}
94
+ style={{ marginTop: 32 }}
95
+ />
82
96
  </div>
83
97
  );
84
98
  }
@@ -1,6 +1,7 @@
1
1
  import type { CSSProperties } from "react";
2
2
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
3
3
  import { useCourseClassroom, type UseCourseClassroomOptions } from "../headless/course/useCourseClassroom";
4
+ import { CourseCallsSection } from "./CourseCallsSection";
4
5
  import { Loading } from "./Loading";
5
6
  import { useForgeT } from "../../i18n";
6
7
 
@@ -14,20 +15,20 @@ export interface CourseAccessProps extends UseCourseClassroomOptions {
14
15
  }
15
16
 
16
17
  /**
17
- * Enrolled course player — module/lesson sidebar, video progress tracking, and
18
+ * Enrolled course player: module/lesson sidebar, video progress tracking, and
18
19
  * the account-binding gate that stands in front of both. Built on the headless
19
20
  * `useCourseClassroom`.
20
21
  *
21
22
  * The three states worth naming, because each one strands somebody if it is
22
23
  * drawn as the others:
23
24
  *
24
- * - **401** — a bound grant opened with no session. Almost always the buyer,
25
+ * - **401**: a bound grant opened with no session. Almost always the buyer,
25
26
  * on an old email link, on a device that never signed in. Gets a sign-in
26
27
  * button that returns HERE, never a dead end.
27
- * - **403** — a bound grant opened as somebody else. Gets "this belongs to a
28
+ * - **403**: a bound grant opened as somebody else. Gets "this belongs to a
28
29
  * different account" and a way OUT of the current session. Never the word
29
30
  * "sign in", which they have already done.
30
- * - **legacy** — no `account_id`, so the link alone is the credential. Opens
31
+ * - **legacy**: no `account_id`, so the link alone is the credential. Opens
31
32
  * normally, with a dismissible note offering to put it behind an account.
32
33
  */
33
34
  export function CourseAccess({
@@ -82,7 +83,7 @@ export function CourseAccess({
82
83
  <p style={{ fontSize: 14, color: tokens.muted, lineHeight: 1.6 }}>{gate.body}</p>
83
84
 
84
85
  {/* Naming the session they are in is what makes "wrong account"
85
- actionable — otherwise the reader has no idea which of their
86
+ actionable, otherwise the reader has no idea which of their
86
87
  addresses they are currently signed in with. */}
87
88
  {gate.kind === "wrong_account" && classroom.signedInEmail && (
88
89
  <p style={{ fontSize: 13, color: tokens.muted }}>
@@ -127,7 +128,7 @@ export function CourseAccess({
127
128
  ...style,
128
129
  }}
129
130
  >
130
- {/* Legacy grant, opened fine. An offer, not an obstacle — inline, muted,
131
+ {/* Legacy grant, opened fine. An offer, not an obstacle: inline, muted,
131
132
  dismissible, and never in front of the course the reader came for. */}
132
133
  {securePrompt && (
133
134
  <div
@@ -311,6 +312,18 @@ export function CourseAccess({
311
312
  )}
312
313
  </main>
313
314
  </div>
315
+
316
+ {/* The student's live calls, where the student already is.
317
+ Scoped to THIS enrolment: `cohortId` is the grant's own cohort, so a
318
+ student holding two grants on one course opens two classrooms and each
319
+ shows only its own sessions. No membership href, so the empty state
320
+ does not sell a membership to a course buyer. */}
321
+ <CourseCallsSection
322
+ courseId={data.access.courseId}
323
+ cohortId={data.access.cohortId ?? null}
324
+ navigate={go}
325
+ style={{ marginTop: 32 }}
326
+ />
314
327
  </div>
315
328
  );
316
329
  }
@@ -0,0 +1,99 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
4
+ import { useForgeT } from "../../i18n";
5
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
6
+ import { MemberCallsCalendar } from "./members/MemberCallsCalendar";
7
+
8
+ export interface CourseCallsSectionProps {
9
+ /** The course this surface is standing on. The classroom reads it off the access row. */
10
+ courseId?: string | null;
11
+ /** The cohort this surface is standing on, when there is one. */
12
+ cohortId?: string | null;
13
+ /** Heading above the calendar. Defaults to the translated "Live calls". */
14
+ title?: string;
15
+ /**
16
+ * Builds the href of the call itself, from its public id. The default is the
17
+ * generic call page, which is where both apps mount it.
18
+ */
19
+ getCallHref?: (publicId: string) => string;
20
+ /** SPA navigation adapter. Defaults to a full-page visit. */
21
+ navigate?: (href: string) => void;
22
+ className?: string;
23
+ style?: CSSProperties;
24
+ }
25
+
26
+ /**
27
+ * A student's live calls, on the page the student is already on.
28
+ *
29
+ * ## Why this is a wrapper and not a calendar
30
+ *
31
+ * `MemberCallsCalendar` is already call-kind agnostic: it renders whatever
32
+ * `listForAttendee` returns, and that resolves course and cohort audiences the
33
+ * same way it resolves membership tiers. A classroom-specific month grid would
34
+ * be a second implementation of the same month, the same open/closed rule and
35
+ * the same join address - which is the dual-surface trap this pillar has been
36
+ * caught by twice. So this file adds exactly two things and nothing else: the
37
+ * SCOPE, and the decision to leave the membership upsell out.
38
+ *
39
+ * ## No membership href, deliberately
40
+ *
41
+ * The members calendar's empty state offers "My membership", which is right
42
+ * where it stands. On a classroom it would sell a membership to somebody who
43
+ * bought a course, so nothing is passed and the button does not render. That is
44
+ * the default in `MemberCallsCalendar`, not a special case here.
45
+ *
46
+ * ## Scope
47
+ *
48
+ * `courseId` and `cohortId` are intersected server-side with the audience keys
49
+ * the caller actually holds, so they narrow and never unlock. Passing BOTH is
50
+ * the classroom's case and is not redundant: a cohort student's schedule is
51
+ * their cohort's sessions PLUS anything addressed to everyone who bought the
52
+ * course. A sibling cohort's sessions are excluded because its key is not in
53
+ * the intersection.
54
+ */
55
+ export function CourseCallsSection({
56
+ courseId,
57
+ cohortId,
58
+ title,
59
+ getCallHref,
60
+ navigate,
61
+ className,
62
+ style,
63
+ }: CourseCallsSectionProps) {
64
+ const translate = useForgeT();
65
+ const t = useThemeTokens();
66
+ const { isAuthenticated, isInitialized } = usePublicAuth();
67
+
68
+ // Nothing to scope by means nothing to show. Rendering the calendar unscoped
69
+ // here would quietly put the viewer's WHOLE schedule - other courses, the
70
+ // membership AMA - on one course's page.
71
+ if (!courseId && !cohortId) return null;
72
+
73
+ /**
74
+ * A seat is minted against an ACCOUNT, so there is nothing to show a reader we
75
+ * cannot identify: the cohort page is anonymous to the public, and a legacy
76
+ * course grant opens for anyone holding the link. Rendering the calendar
77
+ * anyway would fire an authenticated read, take a 401, and put "we could not
78
+ * load your calls" on a page that is working perfectly.
79
+ *
80
+ * `isInitialized` as well, because the session is restored in an effect - a
81
+ * render before it lands looks signed-out to every child.
82
+ */
83
+ if (!isInitialized || !isAuthenticated) return null;
84
+
85
+ return (
86
+ <section
87
+ className={className}
88
+ style={{ display: "flex", flexDirection: "column", gap: 12, color: t.text, fontFamily: t.fontFamily, ...style }}
89
+ >
90
+ <h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{title ?? translate("forge.course_calls.title")}</h2>
91
+ <MemberCallsCalendar
92
+ courseId={courseId}
93
+ cohortId={cohortId}
94
+ getCallHref={getCallHref}
95
+ navigate={navigate}
96
+ />
97
+ </section>
98
+ );
99
+ }
@@ -186,10 +186,20 @@ export function FieldInput({
186
186
  case "yes_no":
187
187
  control = (
188
188
  <div style={{ display: "flex", gap: 16 }}>
189
- {["Yes", "No"].map((opt) => (
190
- <label key={opt} style={{ display: "flex", alignItems: "center", gap: 6 }}>
191
- <input type="radio" name={field.id} checked={value === opt} onChange={() => onChange(opt)} />
192
- {opt}
189
+ {/* Stored value is lowercase "yes"/"no": edge conditions, quiz grading and
190
+ the automation trigger coercion all compare against that, not the label. */}
191
+ {[
192
+ { optValue: "yes", optLabel: "Yes" },
193
+ { optValue: "no", optLabel: "No" },
194
+ ].map(({ optValue, optLabel }) => (
195
+ <label key={optValue} style={{ display: "flex", alignItems: "center", gap: 6 }}>
196
+ <input
197
+ type="radio"
198
+ name={field.id}
199
+ checked={value === optValue}
200
+ onChange={() => onChange(optValue)}
201
+ />
202
+ {optLabel}
193
203
  </label>
194
204
  ))}
195
205
  </div>
@@ -0,0 +1,163 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
6
+ import type { MyMembershipCall } from "../../../data/queries/useMembershipCalls";
7
+
8
+ /**
9
+ * The section is a WRAPPER, so the mock goes under the whole chain
10
+ * (`MemberCallsCalendar` -> `useMemberCalls` -> `useMyMembershipCalls`) rather
11
+ * than over the calendar itself. That is the point of the file: what is under
12
+ * test is the wiring - which scope reaches the query, which empty state the
13
+ * student is shown, and which call id Join opens - not the month grid, which
14
+ * has its own spec.
15
+ */
16
+ const rows: MyMembershipCall[] = [];
17
+ const useMyMembershipCalls = vi.fn();
18
+
19
+ vi.mock("../../../data/queries/useMembershipCalls", () => ({
20
+ useMyMembershipCalls: (options: unknown) => useMyMembershipCalls(options),
21
+ }));
22
+
23
+ let session = { isAuthenticated: true, isInitialized: true };
24
+ vi.mock("../../../contexts/PublicAuthContext", () => ({
25
+ usePublicAuth: () => session,
26
+ }));
27
+
28
+ const { CourseCallsSection } = await import("../CourseCallsSection");
29
+
30
+ /**
31
+ * A day inside the month the grid opens on.
32
+ *
33
+ * `useMemberCalls` lays out the six weeks around the REAL clock and buckets
34
+ * calls into them by the viewer's local day, so a call pinned to a fixed
35
+ * calendar date falls outside the grid the moment that month passes and every
36
+ * assertion here goes quietly green-then-red. Built from `now` instead.
37
+ */
38
+ const at = (hour: number): Date => {
39
+ const now = new Date();
40
+ return new Date(now.getFullYear(), now.getMonth(), 15, hour, 0, 0);
41
+ };
42
+
43
+ const call = (over: Partial<MyMembershipCall> = {}): MyMembershipCall => ({
44
+ id: "call-1",
45
+ title: "Week 1 kickoff",
46
+ description: "Bring your questions.",
47
+ startsAt: at(17).toISOString(),
48
+ endsAt: at(18).toISOString(),
49
+ timezone: "America/New_York",
50
+ participation: "open",
51
+ role: "attendee",
52
+ audiences: [{ id: "a1", audienceType: "course_cohort", audienceId: "cohort-1", label: "March cohort" }],
53
+ opensAt: at(16).toISOString(),
54
+ closesAt: at(20).toISOString(),
55
+ isOpen: true,
56
+ callPublicId: "pub-week-1",
57
+ recording: null,
58
+ ...over,
59
+ });
60
+
61
+ const mount = (props: Record<string, unknown> = {}, navigate = vi.fn()) => {
62
+ const view = render(
63
+ <ForgeThemeProvider theme={{ colors: { text: "#111", background: "#fff", primary: "#6d28d9" }, cornerRadius: 8 }}>
64
+ <CourseCallsSection courseId="course-1" cohortId="cohort-1" navigate={navigate} {...props} />
65
+ </ForgeThemeProvider>,
66
+ );
67
+ return { ...view, navigate };
68
+ };
69
+
70
+ beforeEach(() => {
71
+ rows.length = 0;
72
+ session = { isAuthenticated: true, isInitialized: true };
73
+ useMyMembershipCalls.mockReset();
74
+ useMyMembershipCalls.mockImplementation(() => ({
75
+ data: rows,
76
+ isLoading: false,
77
+ error: null,
78
+ refetch: vi.fn(),
79
+ }));
80
+ });
81
+
82
+ afterEach(cleanup);
83
+
84
+ describe("CourseCallsSection", () => {
85
+ it("renders the student's calls under a heading", () => {
86
+ rows.push(call());
87
+ const { getAllByText } = mount();
88
+ expect(getAllByText("Live calls").length).toBe(1);
89
+ // Desktop grid cell and mobile agenda row are both in the DOM; the
90
+ // calendar renders both faces and hides one with a media class.
91
+ expect(getAllByText("Week 1 kickoff").length).toBeGreaterThan(0);
92
+ });
93
+
94
+ it("scopes the read to the course AND the cohort being viewed", () => {
95
+ rows.push(call());
96
+ mount();
97
+ const options = useMyMembershipCalls.mock.calls[0]![0] as { courseId?: string; cohortId?: string };
98
+ expect(options.courseId).toBe("course-1");
99
+ expect(options.cohortId).toBe("cohort-1");
100
+ });
101
+
102
+ it("passes only the cohort when that is all the surface knows", () => {
103
+ mount({ courseId: null });
104
+ const options = useMyMembershipCalls.mock.calls[0]![0] as { courseId?: string | null; cohortId?: string };
105
+ expect(options.courseId).toBeNull();
106
+ expect(options.cohortId).toBe("cohort-1");
107
+ });
108
+
109
+ it("shows the empty state with NO membership upsell on a course surface", () => {
110
+ const { getByText, queryByText } = mount();
111
+ expect(getByText("Nothing this month")).toBeTruthy();
112
+ // The whole reason this wrapper exists. `MemberCallsCalendar` renders the
113
+ // button only when it is handed a `membershipHref`, and a course-only buyer
114
+ // standing in their own classroom must not be sold a membership.
115
+ expect(queryByText("My membership")).toBeNull();
116
+ });
117
+
118
+ it("links Join to the call's own publicId, on the generic call page", () => {
119
+ rows.push(call({ callPublicId: "pub-week-1" }));
120
+ const { getAllByText, getByText, navigate } = mount();
121
+ fireEvent.click(getAllByText("Week 1 kickoff")[0]!);
122
+ fireEvent.click(getByText("Join call"));
123
+ expect(navigate).toHaveBeenCalledWith("/i/video-calls/pub-week-1");
124
+ });
125
+
126
+ it("offers Watch for a past call whose recording is ready", () => {
127
+ rows.push(
128
+ call({
129
+ id: "call-2",
130
+ title: "Week 0 orientation",
131
+ isOpen: false,
132
+ recording: { id: "rec-1", status: "ready", durationSeconds: 3600, watchUrl: "https://cdn.test/rec.mp4" },
133
+ }),
134
+ );
135
+ const { getAllByText, container } = mount();
136
+ fireEvent.click(getAllByText("Week 0 orientation")[0]!);
137
+ // Queried by href rather than by text: the label carries the duration in a
138
+ // second text node, so a text matcher would be matching the minutes too.
139
+ const watch = container.querySelector<HTMLAnchorElement>('a[href="https://cdn.test/rec.mp4"]');
140
+ expect(watch?.textContent).toContain("Watch recording");
141
+ });
142
+
143
+ it("renders nothing for a reader with no session, rather than a failed read", () => {
144
+ // A legacy course grant opens for anyone holding the link, and the cohort
145
+ // page is anonymous to the public. The attendee endpoint would 401 them, and
146
+ // "we could not load your calls" on a page that is working perfectly is
147
+ // worse than no section at all.
148
+ session = { isAuthenticated: false, isInitialized: true };
149
+ const { container } = mount();
150
+ // Not `textContent`: `ForgeThemeProvider` writes a `:root` custom-property
151
+ // block into the tree, which is there whatever the section does.
152
+ expect(container.querySelector("section")).toBeNull();
153
+ expect(useMyMembershipCalls).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it("renders nothing when there is no course or cohort to scope by", () => {
157
+ // Unscoped here would put the viewer's WHOLE schedule - other courses, the
158
+ // membership AMA - on one course's page.
159
+ const { container } = mount({ courseId: null, cohortId: null });
160
+ expect(container.querySelector("section")).toBeNull();
161
+ expect(useMyMembershipCalls).not.toHaveBeenCalled();
162
+ });
163
+ });
@@ -331,6 +331,9 @@
331
331
  .p-6 {
332
332
  padding: calc(var(--spacing) * 6);
333
333
  }
334
+ .p-10 {
335
+ padding: calc(var(--spacing) * 10);
336
+ }
334
337
  .px-4 {
335
338
  padding-inline: calc(var(--spacing) * 4);
336
339
  }
@@ -465,6 +468,16 @@
465
468
  --tw-ease: var(--ease-out);
466
469
  transition-timing-function: var(--ease-out);
467
470
  }
471
+ .sm\:block {
472
+ @media (width >= 40rem) {
473
+ display: block;
474
+ }
475
+ }
476
+ .sm\:hidden {
477
+ @media (width >= 40rem) {
478
+ display: none;
479
+ }
480
+ }
468
481
  .sm\:grid-cols-2 {
469
482
  @media (width >= 40rem) {
470
483
  grid-template-columns: repeat(2, minmax(0, 1fr));