@domicile-desktop/component-library 0.0.0-alpha-59f4d795d464 → 0.0.0-alpha-9fd298a6794f

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
@@ -2,8 +2,8 @@
2
2
  "dependencies": {
3
3
  "@base-ui/react": "^1.8.0",
4
4
  "@cprussin/option-result": "^2.0.1",
5
- "@domicile-desktop/sdk": "0.0.0-alpha-59f4d795d464",
6
- "@domicile-desktop/system-apps": "0.0.0-alpha-59f4d795d464",
5
+ "@domicile-desktop/sdk": "0.0.0-alpha-9fd298a6794f",
6
+ "@domicile-desktop/system-apps": "0.0.0-alpha-9fd298a6794f",
7
7
  "@pandacss/dev": "^1.12.1",
8
8
  "@phosphor-icons/react": "^2.1.10"
9
9
  },
@@ -110,5 +110,5 @@
110
110
  "test:unit": "bun test"
111
111
  },
112
112
  "type": "module",
113
- "version": "0.0.0-alpha-59f4d795d464"
113
+ "version": "0.0.0-alpha-9fd298a6794f"
114
114
  }
@@ -283,7 +283,9 @@ const Dialog = ({
283
283
  apps={apps}
284
284
  asker={asker}
285
285
  body={request.body}
286
+ browserWindows={browserWindows}
286
287
  screen={screen}
288
+ shownWindows={shownWindows}
287
289
  />
288
290
  );
289
291
  case PortalKind.Print:
@@ -1,24 +1,30 @@
1
- import { Toggle } from "@base-ui/react/toggle";
2
- import { ToggleGroup } from "@base-ui/react/toggle-group";
1
+ import type { DomicileBrowserWindow } from "@domicile-desktop/sdk/domicile-host";
3
2
  import type {
4
3
  CastSource,
4
+ FrozenDesk,
5
5
  PortalAnswer,
6
6
  ScreenCastBody,
7
+ ShotRect,
7
8
  } from "@domicile-desktop/sdk/portal";
8
9
  import {
9
10
  PortalAnswer as Answer,
10
11
  CastSourceKind,
11
12
  CastSource as Source,
12
13
  } from "@domicile-desktop/sdk/portal";
13
- import { AppWindowIcon } from "@phosphor-icons/react/dist/ssr/AppWindow";
14
+ import { GlobeIcon } from "@phosphor-icons/react/dist/ssr/Globe";
14
15
  import { MonitorIcon } from "@phosphor-icons/react/dist/ssr/Monitor";
15
16
  import { useState } from "react";
16
17
  import { css } from "../../styled-system/css";
17
18
  import { Button } from "../Button/Button";
18
19
  import { ModalDialog } from "../ModalDialog/ModalDialog";
19
- import { Tabs } from "../Tabs/Tabs";
20
20
  import type { App } from "../useApps/useApps";
21
+ import { onFrame } from "./on-frame";
21
22
  import { RegionPicker } from "./RegionPicker";
23
+ import type { SourceTile } from "./SourceTiles";
24
+ import { SourceTiles } from "./SourceTiles";
25
+ import type { ShownWindow } from "./shown-windows";
26
+ import { ShownWindowKind } from "./shown-windows";
27
+ import { WindowIcon } from "./WindowIcon";
22
28
 
23
29
  type Props = {
24
30
  answer: (answer: PortalAnswer) => void;
@@ -27,13 +33,18 @@ type Props = {
27
33
  /** Who asks, as the dialog names them. */
28
34
  asker: string;
29
35
  body: ScreenCastBody;
36
+ /** The desktop's browser windows, as the engine lists them. */
37
+ browserWindows: readonly DomicileBrowserWindow[];
30
38
  screen: string | undefined;
39
+ /** The windows the shell draws on screen, read once as the dialog opens. */
40
+ shownWindows: () => readonly ShownWindow[];
31
41
  };
32
42
 
33
43
  /**
34
- * A source picker: the windows and screens an application may record, on a
35
- * tab each when it may have either, and a region drawn on the desk when it
36
- * asks for one. Picks one, or several when the application asks for more.
44
+ * A source picker: the windows, browser windows and screens an application
45
+ * may record, each a tile previewing it on the desk, and a region drawn on
46
+ * the desk when it asks for one. A browser window is its page alone, from
47
+ * the engine. Picks one, or several when the application asks for more.
37
48
  * Dismissing it cancels.
38
49
  */
39
50
  export const ScreenCastDialog = ({
@@ -41,52 +52,49 @@ export const ScreenCastDialog = ({
41
52
  apps,
42
53
  asker,
43
54
  body,
55
+ browserWindows,
44
56
  screen,
57
+ shownWindows,
45
58
  }: Props) => {
59
+ // Read once, as the desk was shot; a later move would not match it.
60
+ const [shown] = useState(shownWindows);
46
61
  const [picked, setPicked] = useState<readonly string[]>([]);
47
62
  const [drawing, setDrawing] = useState(false);
48
63
  const windows = body.sources.filter(
49
64
  (source) => source.kind === CastSourceKind.Window,
50
65
  );
66
+ const browsers = body.browserWindows
67
+ ? browserWindows.map(({ id, title }) => Source.BrowserWindow({ id, title }))
68
+ : [];
51
69
  const monitors = body.sources.filter(
52
70
  (source) => source.kind === CastSourceKind.Monitor,
53
71
  );
54
- const pick = (group: readonly CastSource[], value: readonly string[]) => {
55
- setPicked(
56
- body.multiple
57
- ? [
58
- ...picked.filter(
59
- (key) => !group.some((source) => sourceKey(source) === key),
60
- ),
61
- ...value,
62
- ]
63
- : value,
72
+ const offered = [...windows, ...browsers, ...monitors];
73
+ const group = (label: string, sources: readonly CastSource[]) =>
74
+ sources.length > 0 && (
75
+ <SourceTiles
76
+ desk={body.desk}
77
+ label={label}
78
+ multiple={body.multiple}
79
+ onValueChange={(value) => {
80
+ setPicked(
81
+ body.multiple
82
+ ? [
83
+ ...picked.filter(
84
+ (key) =>
85
+ !sources.some((source) => sourceKey(source) === key),
86
+ ),
87
+ ...value,
88
+ ]
89
+ : value,
90
+ );
91
+ }}
92
+ tiles={sources.map((source) => tileOf(source, apps, body.desk, shown))}
93
+ value={picked.filter((key) =>
94
+ sources.some((source) => sourceKey(source) === key),
95
+ )}
96
+ />
64
97
  );
65
- };
66
- const list = (group: readonly CastSource[]) => (
67
- <ToggleGroup
68
- className={listStyles}
69
- multiple={body.multiple}
70
- onValueChange={(value) => {
71
- pick(group, value);
72
- }}
73
- orientation="vertical"
74
- value={picked.filter((key) =>
75
- group.some((source) => sourceKey(source) === key),
76
- )}
77
- >
78
- {group.map((source) => (
79
- <Toggle
80
- className={rowStyles}
81
- key={sourceKey(source)}
82
- value={sourceKey(source)}
83
- >
84
- <SourceIcon app={appOf(apps, source)} source={source} />
85
- <span>{sourceName(source, appOf(apps, source))}</span>
86
- </Toggle>
87
- ))}
88
- </ToggleGroup>
89
- );
90
98
  return drawing ? (
91
99
  <RegionPicker
92
100
  cancel={() => {
@@ -124,7 +132,7 @@ export const ScreenCastDialog = ({
124
132
  onClick={() => {
125
133
  answer(
126
134
  Answer.ScreenCast(
127
- body.sources.filter((source) =>
135
+ offered.filter((source) =>
128
136
  picked.includes(sourceKey(source)),
129
137
  ),
130
138
  ),
@@ -142,78 +150,34 @@ export const ScreenCastDialog = ({
142
150
  }}
143
151
  open
144
152
  screen={screen}
145
- title={title(body.multiple, windows.length > 0, monitors.length > 0)}
153
+ size="xl"
154
+ title={title(
155
+ body.multiple,
156
+ windows.length + browsers.length > 0,
157
+ monitors.length > 0,
158
+ )}
146
159
  >
147
160
  <p className={askerStyles}>{`${asker} wants to record`}</p>
148
- {windows.length > 0 && monitors.length > 0 ? (
149
- <Tabs
150
- defaultValue="windows"
151
- tabs={[
152
- { content: list(windows), label: "Windows", value: "windows" },
153
- { content: list(monitors), label: "Screens", value: "screens" },
154
- ]}
155
- />
156
- ) : (
157
- list(body.sources)
158
- )}
161
+ <div className={groupsStyles}>
162
+ {group("Windows", windows)}
163
+ {group("Browser windows", browsers)}
164
+ {group("Screens", monitors)}
165
+ </div>
159
166
  </ModalDialog>
160
167
  );
161
168
  };
162
169
 
163
- /** A window's application icon, a window outline without one, or a screen. */
164
- const SourceIcon = ({
165
- app,
166
- source,
167
- }: {
168
- app: App | undefined;
169
- source: CastSource;
170
- }) => {
171
- switch (source.kind) {
172
- case CastSourceKind.Window:
173
- return app?.icon === undefined ? (
174
- <AppWindowIcon aria-hidden className={iconStyles} />
175
- ) : (
176
- <img alt="" className={iconStyles} src={app.icon} />
177
- );
178
- case CastSourceKind.Monitor:
179
- case CastSourceKind.Region:
180
- return <MonitorIcon aria-hidden className={iconStyles} />;
181
- }
182
- };
183
-
184
170
  const askerStyles = css({ color: "muted", fontSize: "sm", margin: 0 });
185
171
 
186
- const listStyles = css({
172
+ const groupsStyles = css({
187
173
  display: "flex",
188
174
  flexDirection: "column",
189
- gap: 1,
175
+ gap: 4,
190
176
  marginBlockStart: 3,
191
- maxBlockSize: 96,
177
+ maxBlockSize: "60vh",
192
178
  overflowY: "auto",
193
179
  });
194
180
 
195
- const rowStyles = css({
196
- _hover: { backgroundColor: "card" },
197
- "&[data-pressed]": {
198
- backgroundColor: "color-mix(in oklab, {colors.accent} 20%, transparent)",
199
- borderColor: "accent",
200
- },
201
- alignItems: "center",
202
- backgroundColor: "transparent",
203
- border: "1px solid transparent",
204
- borderRadius: "md",
205
- color: "foreground",
206
- cursor: "pointer",
207
- display: "flex",
208
- font: "inherit",
209
- gap: 3,
210
- paddingBlock: 2,
211
- paddingInline: 3,
212
- textAlign: "start",
213
- });
214
-
215
- const iconStyles = css({ blockSize: 6, flexShrink: 0, inlineSize: 6 });
216
-
217
181
  /** The dialog's title for what it offers. */
218
182
  const title = (
219
183
  multiple: boolean,
@@ -234,6 +198,8 @@ const sourceKey = (source: CastSource): string => {
234
198
  switch (source.kind) {
235
199
  case CastSourceKind.Window:
236
200
  return `window:${source.id}`;
201
+ case CastSourceKind.BrowserWindow:
202
+ return `browser-window:${source.id}`;
237
203
  case CastSourceKind.Monitor:
238
204
  return `monitor:${source.name}`;
239
205
  case CastSourceKind.Region:
@@ -241,27 +207,68 @@ const sourceKey = (source: CastSource): string => {
241
207
  }
242
208
  };
243
209
 
244
- /** A window's application, or `undefined` for a screen or a window with no app id. */
245
- const appOf = (
246
- apps: (appId: string) => App,
210
+ /** `source`'s tile, previewed where `desk` shows it. */
211
+ const tileOf = (
247
212
  source: CastSource,
248
- ): App | undefined =>
249
- source.kind === CastSourceKind.Window && source.appId !== ""
250
- ? apps(source.appId)
251
- : undefined;
252
-
253
- /** How the picker names a source, a window by its `app` too. */
254
- const sourceName = (source: CastSource, app: App | undefined): string => {
213
+ apps: (appId: string) => App,
214
+ desk: FrozenDesk | undefined,
215
+ shown: readonly ShownWindow[],
216
+ ): SourceTile => {
255
217
  switch (source.kind) {
256
218
  case CastSourceKind.Window: {
257
- const title = source.title === "" ? "Untitled window" : source.title;
258
- return app === undefined ? title : `${app.name}: ${title}`;
219
+ const app = source.appId === "" ? undefined : apps(source.appId);
220
+ const title = titled(source.title);
221
+ return {
222
+ area: shownArea(desk, shown, ShownWindowKind.App, source.id),
223
+ icon: <WindowIcon app={app} />,
224
+ key: sourceKey(source),
225
+ name: app === undefined ? title : `${app.name}: ${title}`,
226
+ };
259
227
  }
228
+ case CastSourceKind.BrowserWindow:
229
+ return {
230
+ area: shownArea(desk, shown, ShownWindowKind.Browser, source.id),
231
+ icon: <GlobeIcon aria-hidden />,
232
+ key: sourceKey(source),
233
+ name: `Browser: ${titled(source.title)}`,
234
+ };
260
235
  case CastSourceKind.Monitor:
261
- return source.description === ""
262
- ? source.name
263
- : `${source.name}: ${source.description}`;
236
+ return {
237
+ area: desk?.monitors.find(({ name }) => name === source.name)?.area,
238
+ icon: <MonitorIcon aria-hidden />,
239
+ key: sourceKey(source),
240
+ name:
241
+ source.description === ""
242
+ ? source.name
243
+ : `${source.name}: ${source.description}`,
244
+ };
264
245
  case CastSourceKind.Region:
265
- return "Region";
246
+ return {
247
+ area: undefined,
248
+ icon: <MonitorIcon aria-hidden />,
249
+ key: sourceKey(source),
250
+ name: "Region",
251
+ };
266
252
  }
267
253
  };
254
+
255
+ /** The name of a window titled `title`, which may be empty. */
256
+ const titled = (title: string): string =>
257
+ title === "" ? "Untitled window" : title;
258
+
259
+ /** Where `desk` shows the window of `kind` and `id`, if it does. */
260
+ const shownArea = (
261
+ desk: FrozenDesk | undefined,
262
+ shown: readonly ShownWindow[],
263
+ kind: ShownWindowKind,
264
+ id: string,
265
+ ): ShotRect | undefined => {
266
+ const window = shown.find((window) =>
267
+ window.kind === ShownWindowKind.App
268
+ ? kind === ShownWindowKind.App && window.appId === id
269
+ : kind === ShownWindowKind.Browser && window.id === id,
270
+ );
271
+ return desk === undefined || window === undefined
272
+ ? undefined
273
+ : onFrame(desk, window.box);
274
+ };
@@ -5,7 +5,6 @@ import type {
5
5
  ShotRect,
6
6
  } from "@domicile-desktop/sdk/portal";
7
7
  import { PortalAnswer as Answer } from "@domicile-desktop/sdk/portal";
8
- import { AppWindowIcon } from "@phosphor-icons/react/dist/ssr/AppWindow";
9
8
  import { GlobeIcon } from "@phosphor-icons/react/dist/ssr/Globe";
10
9
  import { MonitorIcon } from "@phosphor-icons/react/dist/ssr/Monitor";
11
10
  import type { PointerEvent, ReactNode } from "react";
@@ -18,8 +17,11 @@ import type { App } from "../useApps/useApps";
18
17
  import type { FramePoint } from "./frame-point";
19
18
  import { draggedArea, framePoint } from "./frame-point";
20
19
  import { onFrame } from "./on-frame";
20
+ import type { SourceTile } from "./SourceTiles";
21
+ import { SourceTiles } from "./SourceTiles";
21
22
  import type { ShownWindow } from "./shown-windows";
22
23
  import { ShownWindowKind } from "./shown-windows";
24
+ import { WindowIcon } from "./WindowIcon";
23
25
 
24
26
  type Props = {
25
27
  answer: (answer: PortalAnswer) => void;
@@ -71,8 +73,9 @@ type Pick = ReturnType<(typeof Pick)[keyof typeof Pick]>;
71
73
  type Choice = { icon: ReactNode; name: string; pick: Pick };
72
74
 
73
75
  /**
74
- * Picks what to save of a frozen desk: all of it, a screen, a window, or an
75
- * area dragged over it. A window is saved from its own frame, so what is
76
+ * Picks what to save of a frozen desk: all of it, a screen, a window, each
77
+ * a tile previewing it as the screen cast picker's do, or an area dragged
78
+ * over the desk. A window is saved from its own frame, so what is
76
79
  * over it, such as a notification, is left out. Ctrl+C copies the pick to
77
80
  * the clipboard instead of saving it. Dismissing it cancels.
78
81
  */
@@ -105,7 +108,7 @@ export const ScreenshotDialog = ({
105
108
  ...shown.flatMap((window) => browserChoices(body, browserWindows, window)),
106
109
  ];
107
110
  const whole: Choice = {
108
- icon: undefined,
111
+ icon: <MonitorIcon aria-hidden />,
109
112
  name: "Whole desk",
110
113
  pick: Pick.Area({ height: body.height, width: body.width, x: 0, y: 0 }),
111
114
  };
@@ -130,21 +133,32 @@ export const ScreenshotDialog = ({
130
133
  document.removeEventListener("keydown", copy);
131
134
  };
132
135
  }, [answer, pick]);
133
- const button = (choice: Choice, index: number) => (
134
- <Button
135
- aria-pressed={index === picked}
136
- beforeIcon={choice.icon}
137
- // Names repeat, as two untitled windows do; the order is fixed.
138
- key={index}
139
- onClick={() => {
140
- setPicked(index);
141
- }}
142
- size="sm"
143
- variant={index === picked ? "primary" : "outline"}
144
- >
145
- {choice.name}
146
- </Button>
147
- );
136
+ // A choice's tile, keyed by its index: names repeat, as two untitled
137
+ // windows do, and the order is fixed.
138
+ const group = (label: string, members: readonly Choice[], first: number) =>
139
+ members.length > 0 && (
140
+ <SourceTiles
141
+ desk={body}
142
+ label={label}
143
+ multiple={false}
144
+ onValueChange={([value]) => {
145
+ // Picking the picked tile again keeps it.
146
+ if (value !== undefined) {
147
+ setPicked(Number(value));
148
+ }
149
+ }}
150
+ tiles={members.map((choice, offset) =>
151
+ tileOf(choice, String(first + offset)),
152
+ )}
153
+ value={
154
+ typeof picked === "number" &&
155
+ picked >= first &&
156
+ picked < first + members.length
157
+ ? [String(picked)]
158
+ : []
159
+ }
160
+ />
161
+ );
148
162
  const pointAt = (event: PointerEvent<HTMLDivElement>) =>
149
163
  framePoint(
150
164
  { x: event.clientX, y: event.clientY },
@@ -185,19 +199,9 @@ export const ScreenshotDialog = ({
185
199
  >
186
200
  {asker !== undefined && <p className={askerStyles}>{`${asker} asks`}</p>}
187
201
  <div className={choicesStyles}>
188
- {button(whole, 0)}
189
- {screens.length > 0 && (
190
- <ChoiceGroup label="Screens">
191
- {screens.map((choice, offset) => button(choice, 1 + offset))}
192
- </ChoiceGroup>
193
- )}
194
- {windows.length > 0 && (
195
- <ChoiceGroup label="Windows">
196
- {windows.map((choice, offset) =>
197
- button(choice, 1 + screens.length + offset),
198
- )}
199
- </ChoiceGroup>
200
- )}
202
+ {group("Desk", [whole], 0)}
203
+ {group("Screens", screens, 1)}
204
+ {group("Windows", windows, 1 + screens.length)}
201
205
  </div>
202
206
  {pick.kind === PickKind.Window && pick.area === undefined && (
203
207
  <p className={askerStyles}>
@@ -236,41 +240,14 @@ export const ScreenshotDialog = ({
236
240
  );
237
241
  };
238
242
 
239
- /** A labeled row of choices. */
240
- const ChoiceGroup = ({
241
- children,
242
- label,
243
- }: {
244
- children: ReactNode;
245
- label: string;
246
- }) => (
247
- <div aria-label={label} className={groupStyles} role="group">
248
- <span aria-hidden className={groupLabelStyles}>
249
- {label}
250
- </span>
251
- {children}
252
- </div>
253
- );
254
-
255
243
  const askerStyles = css({ color: "muted", fontSize: "sm", margin: 0 });
256
244
 
257
245
  const choicesStyles = flex({
258
- align: "start",
259
246
  direction: "column",
260
- gap: 2,
247
+ gap: 4,
261
248
  marginBlock: 3,
262
249
  });
263
250
 
264
- const groupStyles = flex({ align: "center", gap: 2, wrap: "wrap" });
265
-
266
- const groupLabelStyles = css({
267
- color: "muted",
268
- fontSize: "sm",
269
- minInlineSize: 20,
270
- });
271
-
272
- const appIconStyles = css({ blockSize: 4, inlineSize: 4 });
273
-
274
251
  const deskStyles = css({
275
252
  cursor: "crosshair",
276
253
  // Keeps the shade around the picked area on the frame.
@@ -302,6 +279,14 @@ const placed = (
302
279
  width: `${String((area.width / frame.width) * 100)}%`,
303
280
  });
304
281
 
282
+ /** `choice`'s tile under `key`, previewing what it saves. */
283
+ const tileOf = (choice: Choice, key: string): SourceTile => ({
284
+ area: choice.pick.area,
285
+ icon: choice.icon,
286
+ key,
287
+ name: choice.name,
288
+ });
289
+
305
290
  /** The name of a window titled `title`, which may be empty. */
306
291
  const titled = (title: string): string =>
307
292
  title === "" ? "Untitled window" : title;
@@ -376,12 +361,7 @@ const windowChoice = (
376
361
  title: string,
377
362
  app: App | undefined,
378
363
  ): Choice => ({
379
- icon:
380
- app?.icon === undefined ? (
381
- <AppWindowIcon aria-hidden />
382
- ) : (
383
- <img alt="" className={appIconStyles} src={app.icon} />
384
- ),
364
+ icon: <WindowIcon app={app} />,
385
365
  name: app === undefined ? title : `${app.name}: ${title}`,
386
366
  pick,
387
367
  });
@@ -0,0 +1,173 @@
1
+ import { Toggle } from "@base-ui/react/toggle";
2
+ import { ToggleGroup } from "@base-ui/react/toggle-group";
3
+ import type { FrozenDesk, ShotRect } from "@domicile-desktop/sdk/portal";
4
+ import type { CSSProperties, ReactNode } from "react";
5
+ import { css } from "../../styled-system/css";
6
+
7
+ /** A source a picker offers, as its tile shows it. */
8
+ export type SourceTile = {
9
+ /** Where the source is on the desk's frame, to preview it from. */
10
+ area: ShotRect | undefined;
11
+ /** The source's application icon, or one for its kind. */
12
+ icon: ReactNode;
13
+ /** Unique among the tiles. */
14
+ key: string;
15
+ name: string;
16
+ };
17
+
18
+ type Props = {
19
+ /** The frozen desk to preview the sources on, if there is one. */
20
+ desk: FrozenDesk | undefined;
21
+ label: string;
22
+ multiple: boolean;
23
+ onValueChange: (value: string[]) => void;
24
+ tiles: readonly SourceTile[];
25
+ /** The keys of the tiles picked. */
26
+ value: readonly string[];
27
+ };
28
+
29
+ /**
30
+ * A labeled grid of sources to pick, each a tile previewing it from the
31
+ * frozen desk, or showing its icon large without a preview, over its icon
32
+ * and name. The screen cast and screenshot pickers share it.
33
+ */
34
+ export const SourceTiles = ({
35
+ desk,
36
+ label,
37
+ multiple,
38
+ onValueChange,
39
+ tiles,
40
+ value,
41
+ }: Props) => (
42
+ <div className={groupStyles}>
43
+ <span aria-hidden className={labelStyles}>
44
+ {label}
45
+ </span>
46
+ <ToggleGroup
47
+ aria-label={label}
48
+ className={gridStyles}
49
+ multiple={multiple}
50
+ onValueChange={onValueChange}
51
+ value={[...value]}
52
+ >
53
+ {tiles.map((tile) => (
54
+ <Toggle className={tileStyles} key={tile.key} value={tile.key}>
55
+ <span aria-hidden className={frameStyles}>
56
+ {desk === undefined || tile.area === undefined ? (
57
+ <span className={iconPreviewStyles} data-preview>
58
+ {tile.icon}
59
+ </span>
60
+ ) : (
61
+ <span
62
+ className={shotStyles}
63
+ data-preview
64
+ style={cropped(desk, tile.area)}
65
+ />
66
+ )}
67
+ </span>
68
+ <span className={captionStyles}>
69
+ {tile.icon}
70
+ <span className={nameStyles}>{tile.name}</span>
71
+ </span>
72
+ </Toggle>
73
+ ))}
74
+ </ToggleGroup>
75
+ </div>
76
+ );
77
+
78
+ const groupStyles = css({
79
+ display: "flex",
80
+ flexDirection: "column",
81
+ gap: 2,
82
+ });
83
+
84
+ const labelStyles = css({ color: "muted", fontSize: "sm" });
85
+
86
+ const gridStyles = css({
87
+ display: "grid",
88
+ gap: 2,
89
+ gridTemplateColumns: "repeat(auto-fill, minmax(10rem, 1fr))",
90
+ });
91
+
92
+ const tileStyles = css({
93
+ _hover: { backgroundColor: "card" },
94
+ "&[data-pressed]": {
95
+ backgroundColor: "color-mix(in oklab, {colors.accent} 20%, transparent)",
96
+ borderColor: "accent",
97
+ },
98
+ alignItems: "stretch",
99
+ backgroundColor: "transparent",
100
+ border: "1px solid transparent",
101
+ borderRadius: "md",
102
+ color: "foreground",
103
+ cursor: "pointer",
104
+ display: "flex",
105
+ flexDirection: "column",
106
+ font: "inherit",
107
+ gap: 2,
108
+ minInlineSize: 0,
109
+ padding: 2,
110
+ textAlign: "start",
111
+ });
112
+
113
+ const frameStyles = css({
114
+ alignItems: "center",
115
+ aspectRatio: "16 / 10",
116
+ backgroundColor: "card",
117
+ borderRadius: "sm",
118
+ display: "flex",
119
+ justifyContent: "center",
120
+ overflow: "hidden",
121
+ });
122
+
123
+ const iconPreviewStyles = css({
124
+ "& :is(svg, img)": { blockSize: 10, inlineSize: 10 },
125
+ color: "muted",
126
+ });
127
+
128
+ const shotStyles = css({
129
+ backgroundRepeat: "no-repeat",
130
+ maxBlockSize: "full",
131
+ maxInlineSize: "full",
132
+ });
133
+
134
+ const captionStyles = css({
135
+ "& > :is(svg, img)": { blockSize: 4, flexShrink: 0, inlineSize: 4 },
136
+ alignItems: "center",
137
+ display: "flex",
138
+ fontSize: "sm",
139
+ gap: 2,
140
+ minInlineSize: 0,
141
+ });
142
+
143
+ const nameStyles = css({
144
+ overflow: "hidden",
145
+ textOverflow: "ellipsis",
146
+ whiteSpace: "nowrap",
147
+ });
148
+
149
+ /**
150
+ * Draws `area` of `desk`'s frame filling the tile's frame on its long side,
151
+ * at its own aspect ratio.
152
+ */
153
+ const cropped = (desk: FrozenDesk, area: ShotRect): CSSProperties => ({
154
+ aspectRatio: `${String(area.width)} / ${String(area.height)}`,
155
+ backgroundImage: `url(${desk.frame})`,
156
+ backgroundPosition: `${offset(area.x, area.width, desk.width)} ${offset(area.y, area.height, desk.height)}`,
157
+ backgroundSize: `${percent(desk.width, area.width)} ${percent(desk.height, area.height)}`,
158
+ // Wider than the frame's 16:10 fills its width; taller fills its height.
159
+ ...(area.width * 10 >= area.height * 16
160
+ ? { inlineSize: "100%" }
161
+ : { blockSize: "100%" }),
162
+ });
163
+
164
+ /** `whole` as a percent of `part`. */
165
+ const percent = (whole: number, part: number): string =>
166
+ `${String((whole / part) * 100)}%`;
167
+
168
+ /**
169
+ * The background position that puts `start` of a `whole` frame at the
170
+ * edge of a `length` box.
171
+ */
172
+ const offset = (start: number, length: number, whole: number): string =>
173
+ whole === length ? "0%" : `${String((start / (whole - length)) * 100)}%`;
@@ -0,0 +1,10 @@
1
+ import { AppWindowIcon } from "@phosphor-icons/react/dist/ssr/AppWindow";
2
+ import type { App } from "../useApps/useApps";
3
+
4
+ /** A window's application icon, or a window outline without one. */
5
+ export const WindowIcon = ({ app }: { app: App | undefined }) =>
6
+ app?.icon === undefined ? (
7
+ <AppWindowIcon aria-hidden />
8
+ ) : (
9
+ <img alt="" src={app.icon} />
10
+ );
@@ -6,6 +6,8 @@ export const sourceName = (source: Captured): string => {
6
6
  switch (source.kind) {
7
7
  case CapturedKind.Window:
8
8
  return source.title === "" ? "Untitled window" : source.title;
9
+ case CapturedKind.BrowserWindow:
10
+ return `Browser: ${source.title}`;
9
11
  case CapturedKind.Monitor:
10
12
  return `Screen ${source.name}`;
11
13
  case CapturedKind.Region: