@yannelli/paseo-linear-plugin 1.1.0-beta.1 → 1.1.0-beta.2

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/OVERVIEW.md CHANGED
@@ -31,6 +31,10 @@ Open **Linear Live** from the Linear pill in an agent's composer. It shows the i
31
31
 
32
32
  On agents not started from an issue, the Linear pill searches Linear and sends an issue to the agent.
33
33
 
34
+ ## Plugin icon
35
+
36
+ In **Settings → Plugins → Linear → Icon**, choose an SVG file or paste SVG markup. Composer pills, the Linear sidebar row, the timeline card, and the Linear screens use it. Paseo menus and panel tabs keep the built-in icon. Keep the original colors, or paint the icon with the theme color, the theme accent, Linear indigo, or a custom color. **Solid** fills outline shapes. The plugin refuses SVGs with scripts, event handlers, or links to other files. The iOS and Android apps show the built-in icon in the chosen color.
37
+
34
38
  ## Update Linear from todos
35
39
 
36
40
  Turn on **Update Linear from agent todos** in the Live settings. After each turn, a todo that starts with a sub-issue key, such as `ENG-124: add the queue`, moves that sub-issue to In Progress, and then to Done when all its todos are done. The parent moves to In Review when every sub-issue is done, never to Done. Statuses only move forward.
package/README.md CHANGED
@@ -179,6 +179,15 @@ For implement agents on an issue with sub-issues, the prompt asks the agent to s
179
179
 
180
180
  Each agent's composer has a **Linear** pill. On an agent started from an issue, the pill shows the issue key. It opens the issue status, each sub-issue with its todo progress, and buttons for Linear Live, the issue, and **Sync now**. On other agents, the pill searches Linear and sends the issue text to the agent as a message.
181
181
 
182
+ ### Plugin icon
183
+
184
+ In **Settings → Plugins → Linear → Icon**, choose an SVG file or paste SVG markup. Composer pills, the Linear sidebar row on Paseo 0.11 and newer, the issue card in the timeline, and the Linear screens then use it. Paseo menus, panel tabs, and settings take only built-in icon names, so they keep the Linear board icon. The plugin accepts SVG only. It refuses scripts, event handlers, HTML content, entities, and links to other files or bitmap images. The SVG needs a viewBox, or a width and height in pixels.
185
+
186
+ - **Color:** **Original colors** keeps the SVG as drawn, and `currentColor` takes the color Paseo gives the icon. **Theme color**, **Theme accent**, **Linear indigo**, and **Custom color** paint the whole icon one color and keep its transparency. These choices also apply to the built-in icon.
187
+ - **Solid:** fills shapes that only have an outline, with their stroke color, so a line icon shows as a solid shape.
188
+
189
+ The preview shows each change before you save. The iOS and Android apps cannot draw SVG images from plugins, so they show the built-in icon in the chosen color.
190
+
182
191
  ### Update Linear from agent todos
183
192
 
184
193
  Turn on **Update Linear from agent todos** in the Live settings. After each turn of an implement agent, the daemon reads the agent's todos:
@@ -203,6 +212,7 @@ In **Settings → Plugins → Linear → Projects**, select **Start** under **Se
203
212
  - **Linear tools for agents:** the daemon runs the `linear` MCP server on `127.0.0.1` only, and only after an agent gets it. Each agent gets its own random token, and its tools reach only its issue and that issue's sub-issues. Edits use the Linear key of the project that loaded the issue. Requests from a web page are refused. `agent-tools.json` beside the key file keeps the port and a hash of each token, so agents keep their tools after a restart. The token itself is in the agent's Paseo config.
204
213
  - **Claude hooks:** when an **Agent guidance** switch is on for a Claude agent, the daemon writes a Claude Code plugin to `claude-hooks/` beside the key file, one folder for each issue and set of hooks. It holds the issue key and title and the sub-issue keys and titles. The agent starts with `--plugin-dir` set to that folder. The hooks print that text and run no other command. The daemon does not write hooks on Windows hosts.
205
214
  - **Todo sync:** when it is on, the daemon changes issue statuses in Linear with the key of the project that loaded the issue.
215
+ - **Plugin icon:** the SVG is saved in the plugin settings on the daemon host. The app draws it as an image, which does not run scripts or load other files.
206
216
  - **Cached results:** the daemon keeps up to 300 recent Linear responses in memory for up to 24 hours. It groups them by a hash of the key, so two keys never share results. The app shows saved results at once with **Showing saved results. Updating…** while it gets new data. An edit clears the cached lists and issues for that key. Saving or removing a key clears the whole cache. The cache is not written to disk, and a daemon restart clears it.
207
217
 
208
218
  Linear errors, such as a rejected key or a rate limit, show in the panel or as a message.
@@ -211,7 +221,7 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
211
221
 
212
222
  | File | Runtime | Role |
213
223
  | --- | --- | --- |
214
- | `index.client.tsx` | App | Registers the Linear screen, the workspace panel, the Linear Live panel, the Account, Prompts, Live, and Projects settings, the command center items, the `/linear` command, the timeline card, the composer attachment, and the Linear pill |
224
+ | `index.client.tsx` | App | Registers the Linear screen, the workspace panel, the Linear Live panel, the Account, Prompts, Live, Icon, and Projects settings, the command center items, the `/linear` command, the timeline card, the composer attachment, and the Linear pill |
215
225
  | `index.server.ts` | Daemon | Registers the settings, the RPC handlers, and the todo sync hook |
216
226
  | `client/browser.tsx` | App | Screen and panel: picks the key, then shows the list, the issue, or the agent setup page |
217
227
  | `client/issue-list.tsx`, `client/issue-row.tsx`, `client/issue-tree.ts` | App | Search, filters, sort, rows, status groups, and sub-issue nesting |
@@ -220,6 +230,7 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
220
230
  | `client/markdown.tsx` | App | Renders Markdown |
221
231
  | `client/launch.tsx`, `client/launch-fields.tsx`, `client/launch-guidance.tsx`, `client/launch-choices.ts`, `client/launch-plan.ts`, `client/agent-options.ts` | App | Agent setup page, Run in options, agent guidance, and agent start |
222
232
  | `client/model-browser.tsx`, `client/provider-icon.tsx` | App | Model picker and provider icons |
233
+ | `client/plugin-icon.tsx`, `client/settings-icon.tsx`, `shared/custom-icon.ts` | App, Both | The plugin icon, its settings screen, and the SVG checks, colors, and fill |
223
234
  | `client/create-issue.tsx`, `client/pickers.tsx` | App | New issue form and option pickers |
224
235
  | `client/connect.tsx`, `client/settings-*.tsx` | App | Key form and settings screens |
225
236
  | `client/queries.ts`, `client/store.ts`, `client/key-scope.tsx` | App | Data hooks with saved results, browser state, and the key in use |
@@ -227,7 +238,7 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
227
238
  | `client/timeline-card.tsx`, `client/ui.tsx`, `client/glyphs.tsx` | App | Timeline card, shared controls, and icons |
228
239
  | `client/live-panel.tsx`, `client/live-header.tsx`, `client/live-issues.tsx`, `client/live-feed.tsx`, `client/live-timeline.ts` | App | Linear Live panel, header, sub-issues, activity, and the agent timeline feed |
229
240
  | `client/live-map.tsx`, `client/live-graph.tsx`, `client/graph-labels.ts`, `client/graph-camera.tsx`, `client/live-cursor.tsx`, `client/live-agents.tsx` | App | File map, file graph with its labels and camera, agent cursors, and the agents list |
230
- | `client/tool-buttons.tsx`, `client/web.ts` | App | Icon buttons for the map and graph, and the wheel listener on the web |
241
+ | `client/tool-buttons.tsx`, `client/web.ts` | App | Icon buttons for the map and graph, and the wheel listener and file picker on the web |
231
242
  | `client/pill.tsx` | App | Linear pill in each agent's composer |
232
243
  | `client/project-init.tsx` | App | Project setup proposal in the Projects settings |
233
244
  | `shared/linear.ts` | Both | RPC contracts and issue schemas |
@@ -263,7 +274,7 @@ npm run check
263
274
  paseo plugin add "$PWD"
264
275
  ```
265
276
 
266
- After you edit the source, run `paseo plugin reload paseo-linear-plugin`. `npm run check` runs the typecheck and the tests. The tests cover the Linear client, the key file, the cache, the handlers, sub-issue nesting, the Markdown parser and task list toggle, the description autosave, the agent options, the prompt guidance and its hooks, the MCP server and its tools, the links between files in many languages, the graph layout and camera, and the release scripts. They use a local GraphQL server and do not call Linear.
277
+ After you edit the source, run `paseo plugin reload paseo-linear-plugin`. `npm run check` runs the typecheck and the tests. The tests cover the Linear client, the key file, the cache, the handlers, sub-issue nesting, the Markdown parser and task list toggle, the description autosave, the agent options, the prompt guidance and its hooks, the MCP server and its tools, the links between files in many languages, the graph layout and camera, the SVG icon checks and colors, and the release scripts. They use a local GraphQL server and do not call Linear.
267
278
 
268
279
  ## Graphics
269
280
 
package/client/access.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type PluginAgentPanelProps, useSettings, useWorkspace } from "@getpaseo/plugin/client";
2
2
  import { type ComponentType, type ReactNode, useEffect } from "react";
3
3
  import { linearSettings, type ProjectAccess, projectEnabled } from "../shared/settings";
4
+ import { PluginIcon } from "./plugin-icon";
4
5
  import { EmptyState, type Theme } from "./ui";
5
6
 
6
7
  // Composer pills live outside React, so components that read settings share the project
@@ -44,7 +45,7 @@ export function ProjectGate(props: {
44
45
  return (
45
46
  <EmptyState
46
47
  theme={props.theme}
47
- icon="SquareKanban"
48
+ icon={PluginIcon}
48
49
  title="Linear is off for this project"
49
50
  detail="Turn it on in Linear settings, under Projects."
50
51
  />
package/client/compat.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client";
2
2
  import * as hostUi from "@getpaseo/plugin/client/ui";
3
- import { type ComponentType, createElement } from "react";
3
+ import { type ComponentType, createContext, createElement, useContext } from "react";
4
4
 
5
5
  // Paseo 0.11 replaced surfaces with screens and sidebar header items. 0.10 only has
6
6
  // addSurface/addSidebarItem; later releases drop those aliases. Detect once, register once.
@@ -13,6 +13,7 @@ interface ScreenRef {
13
13
  interface SidebarItemProps {
14
14
  currentScreen: ScreenRef | null;
15
15
  openScreen(input: ScreenRef): void;
16
+ theme?: PluginSurfaceProps["theme"];
16
17
  }
17
18
 
18
19
  interface ScreenClient {
@@ -33,8 +34,13 @@ interface SurfaceClient {
33
34
  addSidebarItem(input: { id: string; title: string; icon: string; surface: string }): Cleanup;
34
35
  }
35
36
 
37
+ interface RowIconProps {
38
+ size: number;
39
+ color: string;
40
+ }
41
+
36
42
  type SidebarRowComponent = ComponentType<{
37
- icon?: string;
43
+ icon?: string | ComponentType<RowIconProps>;
38
44
  label?: string;
39
45
  active?: boolean;
40
46
  onPress(): void;
@@ -48,9 +54,14 @@ export interface ScreenRegistration {
48
54
  id: string;
49
55
  title: string;
50
56
  icon: string;
57
+ /** Drawn in the sidebar row where the host accepts icon components; else `icon`. */
58
+ RowIcon?: ComponentType<RowIconProps & { theme: PluginSurfaceProps["theme"] | null }>;
51
59
  Component: ComponentType<PluginSurfaceProps>;
52
60
  }
53
61
 
62
+ // The row draws its icon with a size and a color only, so the item passes its theme down.
63
+ const RowTheme = createContext<PluginSurfaceProps["theme"] | null>(null);
64
+
54
65
  export function registerScreen(client: PluginClientContext, screen: ScreenRegistration): Cleanup {
55
66
  const SidebarRow = (hostUi as Record<string, unknown>).SidebarRow as
56
67
  | SidebarRowComponent
@@ -61,12 +72,19 @@ export function registerScreen(client: PluginClientContext, screen: ScreenRegist
61
72
  title: screen.title,
62
73
  Component: screen.Component,
63
74
  });
64
- function SidebarItem({ currentScreen, openScreen }: SidebarItemProps) {
65
- return createElement(SidebarRow as SidebarRowComponent, {
66
- icon: screen.icon,
75
+ const { RowIcon } = screen;
76
+ const ThemedIcon = RowIcon
77
+ ? function ThemedIcon(props: RowIconProps) {
78
+ return createElement(RowIcon, { ...props, theme: useContext(RowTheme) });
79
+ }
80
+ : null;
81
+ function SidebarItem({ currentScreen, openScreen, theme }: SidebarItemProps) {
82
+ const row = createElement(SidebarRow as SidebarRowComponent, {
83
+ icon: ThemedIcon ?? screen.icon,
67
84
  active: currentScreen?.screenId === screen.id,
68
85
  onPress: () => openScreen({ screenId: screen.id }),
69
86
  });
87
+ return createElement(RowTheme.Provider, { value: theme ?? null }, row);
70
88
  }
71
89
  const removeItem = client.addSidebarHeaderItem({
72
90
  id: screen.id,
@@ -1,10 +1,11 @@
1
1
  import { useRpc } from "@getpaseo/plugin/client";
2
- import { Icon, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
2
+ import { TextInput, useToast } from "@getpaseo/plugin/client/react-native";
3
3
  import { ExternalLink } from "@getpaseo/plugin/client/ui";
4
4
  import { useMutation, useQueryClient } from "@tanstack/react-query";
5
5
  import { useCallback, useMemo, useState } from "react";
6
6
  import { Text, View } from "react-native";
7
7
  import { authSaveRpc } from "../shared/linear";
8
+ import { PluginIcon } from "./plugin-icon";
8
9
  import { Button, errorMessage, type Theme } from "./ui";
9
10
 
10
11
  export const LINEAR_KEY_SETTINGS_URL = "https://linear.app/settings/account/security";
@@ -121,7 +122,7 @@ export function ConnectCard({ theme, compact }: { theme: Theme; compact: boolean
121
122
  <View style={styles.root}>
122
123
  <View style={styles.card}>
123
124
  <View style={styles.header}>
124
- <Icon name="SquareKanban" size={22} color={colors.foreground} />
125
+ <PluginIcon size={22} color={colors.foreground} theme={theme} />
125
126
  <Text style={styles.title}>Connect Linear</Text>
126
127
  </View>
127
128
  <Text style={styles.body}>
package/client/pill.tsx CHANGED
@@ -17,6 +17,7 @@ import { progressByKey } from "../shared/todo-sync";
17
17
  import { currentAccess, onAccessChange, publishAccess } from "./access";
18
18
  import { KeyScopeProvider } from "./key-scope";
19
19
  import { MONO } from "./live-issues";
20
+ import { PluginIcon } from "./plugin-icon";
20
21
  import { useIssue } from "./queries";
21
22
  import { focusIssue, panelScope } from "./store";
22
23
  import { Button, errorMessage, StateIcon, type Theme } from "./ui";
@@ -144,7 +145,7 @@ function LinkedIssue(props: ContentProps & { open: Open }) {
144
145
  <Button
145
146
  theme={theme}
146
147
  size="xs"
147
- icon="SquareKanban"
148
+ icon={PluginIcon}
148
149
  label="Open issue"
149
150
  onPress={() => {
150
151
  focusIssue(panelScope(workspaceId), identifier);
@@ -337,13 +338,13 @@ export function contributeComposerPills(client: PluginClientContext) {
337
338
  button: identifier
338
339
  ? {
339
340
  title: `Linear issue ${identifier}`,
340
- icon: "SquareKanban",
341
+ icon: PluginIcon,
341
342
  label: identifier,
342
343
  behavior: { kind: "popover", Content: Linked },
343
344
  }
344
345
  : {
345
346
  title: "Send a Linear issue to this agent",
346
- icon: "SquareKanban",
347
+ icon: PluginIcon,
347
348
  label: "Linear",
348
349
  behavior: { kind: "popover", Content: Search },
349
350
  },
@@ -0,0 +1,43 @@
1
+ import { useSettings } from "@getpaseo/plugin/client";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { useMemo } from "react";
4
+ import { Image, Platform } from "react-native";
5
+ import { type IconSettings, iconMarkup, paintColor } from "../shared/custom-icon";
6
+ import { linearSettings } from "../shared/settings";
7
+ import { svgDataUri } from "./provider-icon";
8
+ import type { Theme } from "./ui";
9
+
10
+ // The plugin's icon in pills, the sidebar, and plugin screens. Native apps cannot draw SVG
11
+ // images from plugins, so they show the built-in icon in the chosen color.
12
+
13
+ export const BUILT_IN_ICON = "SquareKanban";
14
+
15
+ export interface PluginIconProps {
16
+ size: number;
17
+ /** The color Paseo gives the icon in this place. */
18
+ color: string;
19
+ theme?: Theme | null;
20
+ }
21
+
22
+ /** Draws icon settings, saved or not yet saved. */
23
+ export function IconGlyph(props: PluginIconProps & { icon: IconSettings | null }) {
24
+ const { icon, size, color } = props;
25
+ const accent = props.theme?.colors.accent ?? color;
26
+ const paint = icon ? paintColor(icon, { ink: color, accent }) : null;
27
+ const svg = Platform.OS === "web" ? (icon?.svg ?? "") : "";
28
+ const solid = icon?.solid === true;
29
+ const source = useMemo(() => {
30
+ const markup = svg ? iconMarkup(svg, { ink: color, paint, solid }) : null;
31
+ return markup ? { uri: svgDataUri(markup, color) } : null;
32
+ }, [svg, color, paint, solid]);
33
+ const style = useMemo(() => ({ width: size, height: size }), [size]);
34
+ if (!source) return <Icon name={BUILT_IN_ICON} size={size} color={paint ?? color} />;
35
+ return <Image source={source} style={style} resizeMode="contain" accessibilityIgnoresInvertColors />;
36
+ }
37
+
38
+ /** The plugin icon from the saved settings. The built-in icon shows while they load. */
39
+ export function PluginIcon(props: PluginIconProps) {
40
+ const settings = useSettings(linearSettings);
41
+ const icon = settings.status === "ready" ? settings.values.icon : null;
42
+ return <IconGlyph {...props} icon={icon} />;
43
+ }
@@ -5,6 +5,7 @@ import { type ReactNode, useCallback, useMemo, useState } from "react";
5
5
  import { Text, View } from "react-native";
6
6
  import { TEMPLATE_VARIABLES } from "../shared/prompts";
7
7
  import type { LinearSettings, linearSettings } from "../shared/settings";
8
+ import { MONO } from "./live-issues";
8
9
  import type { Theme } from "./ui";
9
10
 
10
11
  export type ReadySettings = Extract<
@@ -19,10 +20,14 @@ export function MultilineField(props: {
19
20
  value: string;
20
21
  placeholder?: string;
21
22
  minHeight?: number;
23
+ error?: string | null;
24
+ /** Markup, not prose: a monospace font and no autocorrect. */
25
+ code?: boolean;
22
26
  onChange(value: string): void;
23
27
  }) {
24
28
  const { colors } = props.theme;
25
29
  const minHeight = props.minHeight ?? 96;
30
+ const { code } = props;
26
31
  const style = useMemo(
27
32
  () => ({
28
33
  marginTop: 8,
@@ -36,11 +41,12 @@ export function MultilineField(props: {
36
41
  fontSize: 13,
37
42
  lineHeight: 19,
38
43
  textAlignVertical: "top" as const,
44
+ ...(code ? { fontFamily: MONO, fontSize: 12 } : {}),
39
45
  }),
40
- [colors, minHeight],
46
+ [colors, minHeight, code],
41
47
  );
42
48
  return (
43
- <SettingsRow label={props.label} hint={props.hint}>
49
+ <SettingsRow label={props.label} hint={props.hint} error={props.error}>
44
50
  <TextInput
45
51
  value={props.value}
46
52
  onChangeText={props.onChange}
@@ -48,7 +54,9 @@ export function MultilineField(props: {
48
54
  placeholder={props.placeholder}
49
55
  placeholderTextColor={colors.foregroundMuted}
50
56
  accessibilityLabel={props.label}
51
- autoCapitalize="sentences"
57
+ autoCapitalize={code ? "none" : "sentences"}
58
+ autoCorrect={!code}
59
+ spellCheck={!code}
52
60
  style={style}
53
61
  />
54
62
  </SettingsRow>
@@ -0,0 +1,228 @@
1
+ import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
2
+ import {
3
+ SettingsAction,
4
+ SettingsCard,
5
+ SettingsInput,
6
+ SettingsRow,
7
+ SettingsSection,
8
+ SettingsSelect,
9
+ SettingsSwitch,
10
+ } from "@getpaseo/plugin/client/ui";
11
+ import { useCallback, useMemo, useState } from "react";
12
+ import { Platform, Text, View } from "react-native";
13
+ import {
14
+ HEX_COLOR,
15
+ type IconPaint,
16
+ type IconSettings,
17
+ MAX_SVG_LENGTH,
18
+ parseIconSvg,
19
+ } from "../shared/custom-icon";
20
+ import { linearSettings } from "../shared/settings";
21
+ import { IconGlyph } from "./plugin-icon";
22
+ import { MultilineField, type ReadySettings, SaveBar, SettingsGate, useSettingsDraft } from "./settings-fields";
23
+ import type { Theme } from "./ui";
24
+ import { canPickFiles, pickTextFile } from "./web";
25
+
26
+ const PAINT_OPTIONS: readonly { label: string; value: IconPaint }[] = [
27
+ { label: "Original colors", value: "original" },
28
+ { label: "Theme color, like Paseo's icons", value: "theme" },
29
+ { label: "Theme accent", value: "accent" },
30
+ { label: "Linear indigo, the plugin color", value: "linear" },
31
+ { label: "Custom color", value: "custom" },
32
+ ];
33
+ const PREVIEW_SIZES = [14, 16, 22, 28] as const;
34
+ const SVG_FILES = ".svg,image/svg+xml";
35
+ /** Exports often carry comments and metadata, so the file may be larger than the saved SVG. */
36
+ const MAX_FILE_BYTES = MAX_SVG_LENGTH * 4;
37
+
38
+ export function IconSettingsScreen({ theme }: PluginSurfaceProps) {
39
+ const settings = useSettings(linearSettings);
40
+ const render = useCallback(
41
+ (ready: ReadySettings) => <IconEditor theme={theme} settings={ready} />,
42
+ [theme],
43
+ );
44
+ return (
45
+ <SettingsGate theme={theme} title="Icon" settings={settings}>
46
+ {render}
47
+ </SettingsGate>
48
+ );
49
+ }
50
+
51
+ function IconPreview({ theme, icon }: { theme: Theme; icon: IconSettings }) {
52
+ const { colors } = theme;
53
+ const styles = useMemo(
54
+ () => ({
55
+ row: { flexDirection: "row" as const, flexWrap: "wrap" as const, alignItems: "center" as const, gap: 14, paddingTop: 8 },
56
+ pill: {
57
+ flexDirection: "row" as const,
58
+ alignItems: "center" as const,
59
+ gap: 6,
60
+ height: 26,
61
+ paddingHorizontal: 10,
62
+ borderRadius: 13,
63
+ borderWidth: 1,
64
+ borderColor: colors.border,
65
+ backgroundColor: colors.surface1,
66
+ },
67
+ label: { color: colors.foreground, fontSize: 12 },
68
+ }),
69
+ [colors],
70
+ );
71
+ return (
72
+ <View style={styles.row}>
73
+ {PREVIEW_SIZES.map((size) => (
74
+ <IconGlyph key={size} icon={icon} size={size} color={colors.foregroundMuted} theme={theme} />
75
+ ))}
76
+ <View style={styles.pill}>
77
+ <IconGlyph icon={icon} size={14} color={colors.foregroundMuted} theme={theme} />
78
+ <Text style={styles.label}>ENG-123</Text>
79
+ </View>
80
+ <View style={styles.pill}>
81
+ <IconGlyph icon={icon} size={14} color={colors.foreground} theme={theme} />
82
+ <Text style={styles.label}>Linear</Text>
83
+ </View>
84
+ </View>
85
+ );
86
+ }
87
+
88
+ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
89
+ const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
90
+ const icon = values.icon;
91
+ const [markup, setMarkup] = useState(icon.svg);
92
+ const [markupError, setMarkupError] = useState<string | null>(null);
93
+ const [colorError, setColorError] = useState<string | null>(null);
94
+ const [fileName, setFileName] = useState<string | null>(null);
95
+ const change = useCallback(
96
+ (patch: Partial<IconSettings>) => edit((current) => ({ ...current, icon: { ...current.icon, ...patch } })),
97
+ [edit],
98
+ );
99
+ // The draft keeps the last SVG that passed the checks, so the preview never breaks.
100
+ const applyMarkup = useCallback(
101
+ (text: string) => {
102
+ setMarkup(text);
103
+ if (!text.trim()) {
104
+ setMarkupError(null);
105
+ change({ svg: "" });
106
+ return;
107
+ }
108
+ const parsed = parseIconSvg(text);
109
+ setMarkupError("error" in parsed ? parsed.error : null);
110
+ if ("svg" in parsed) change({ svg: parsed.svg });
111
+ },
112
+ [change],
113
+ );
114
+ const choose = useCallback(
115
+ () =>
116
+ pickTextFile(SVG_FILES, MAX_FILE_BYTES, (file) => {
117
+ if ("error" in file) {
118
+ setMarkupError(file.error);
119
+ return;
120
+ }
121
+ setFileName(file.name);
122
+ applyMarkup(file.text);
123
+ }),
124
+ [applyMarkup],
125
+ );
126
+ const useBuiltIn = useCallback(() => {
127
+ setFileName(null);
128
+ applyMarkup("");
129
+ }, [applyMarkup]);
130
+ const changeColor = useCallback(
131
+ (text: string) => {
132
+ const color = text.trim();
133
+ const valid = HEX_COLOR.test(color);
134
+ setColorError(valid ? null : "Use a hex color, such as #5E6AD2.");
135
+ if (valid) change({ color });
136
+ },
137
+ [change],
138
+ );
139
+ const reset = useCallback(() => {
140
+ discard();
141
+ setMarkup(settings.values.icon.svg);
142
+ setMarkupError(null);
143
+ setColorError(null);
144
+ setFileName(null);
145
+ }, [discard, settings.values.icon.svg]);
146
+ const native = Platform.OS !== "web";
147
+ const custom = icon.svg !== "";
148
+
149
+ return (
150
+ <View>
151
+ <SettingsSection title="Plugin icon">
152
+ <SettingsCard>
153
+ <SettingsRow
154
+ label="Preview"
155
+ hint={
156
+ native
157
+ ? "The iOS and Android apps cannot draw plugin SVG images, so they show the built-in icon in the color you choose."
158
+ : "Composer pills, the Linear sidebar row, and Linear screens use this icon. Paseo menus and panel tabs keep the built-in icon."
159
+ }
160
+ >
161
+ <IconPreview theme={theme} icon={icon} />
162
+ </SettingsRow>
163
+ {canPickFiles ? (
164
+ <SettingsAction
165
+ label="SVG file"
166
+ hint={fileName ? `Loaded ${fileName}. Save to use it.` : "Choose an .svg file. The plugin saves a copy in its settings."}
167
+ actionLabel="Choose file"
168
+ onPress={choose}
169
+ />
170
+ ) : null}
171
+ <MultilineField
172
+ theme={theme}
173
+ label="SVG markup"
174
+ hint="Paste SVG markup, or edit it here. Scripts, links to other files, and bitmap images are not allowed. Clear it to use the built-in icon."
175
+ value={markup}
176
+ placeholder={'<svg viewBox="0 0 24 24">…</svg>'}
177
+ error={markupError}
178
+ code
179
+ onChange={applyMarkup}
180
+ />
181
+ <SettingsAction
182
+ label="Built-in icon"
183
+ hint="Remove your SVG and use the Linear board icon."
184
+ actionLabel="Use built-in"
185
+ disabled={!custom && markup === ""}
186
+ onPress={useBuiltIn}
187
+ />
188
+ </SettingsCard>
189
+ </SettingsSection>
190
+ <SettingsSection title="Color and fill">
191
+ <SettingsCard>
192
+ <SettingsSelect
193
+ label="Color"
194
+ hint="Original colors keep the SVG as drawn, and currentColor follows Paseo. The other choices paint the whole icon one color."
195
+ value={icon.paint}
196
+ options={PAINT_OPTIONS}
197
+ onValueChange={(paint) => change({ paint })}
198
+ />
199
+ {icon.paint === "custom" ? (
200
+ <SettingsInput
201
+ label="Custom color"
202
+ hint="A hex color, such as #5E6AD2 or #5E6AD2CC with transparency."
203
+ initialValue={icon.color}
204
+ placeholder="#5E6AD2"
205
+ error={colorError}
206
+ onChangeText={changeColor}
207
+ />
208
+ ) : null}
209
+ <SettingsSwitch
210
+ label="Solid"
211
+ hint={custom ? "Fill shapes that only have an outline, so line icons show as solid shapes." : "Add your SVG to use this. The built-in icon stays as drawn."}
212
+ value={icon.solid}
213
+ disabled={!custom}
214
+ onValueChange={(solid) => change({ solid })}
215
+ />
216
+ </SettingsCard>
217
+ </SettingsSection>
218
+ <SaveBar
219
+ theme={theme}
220
+ dirty={dirty}
221
+ saving={settings.saving}
222
+ error={settings.saveError}
223
+ onSave={() => void save()}
224
+ onDiscard={reset}
225
+ />
226
+ </View>
227
+ );
228
+ }
@@ -4,6 +4,7 @@ import { useCallback, useMemo } from "react";
4
4
  import { Pressable, Text, View } from "react-native";
5
5
  import type { IssueCard } from "../shared/linear";
6
6
  import { ACTION_LABELS } from "../shared/prompts";
7
+ import { PluginIcon } from "./plugin-icon";
7
8
  import { usePressableStyle } from "./ui";
8
9
 
9
10
  export function IssueCardRow({ item, theme }: PluginTimelineItemProps<IssueCard>) {
@@ -44,7 +45,7 @@ export function IssueCardRow({ item, theme }: PluginTimelineItemProps<IssueCard>
44
45
  onPress={open}
45
46
  style={style}
46
47
  >
47
- <Icon name="SquareKanban" size={16} color={colors.foregroundMuted} />
48
+ <PluginIcon size={16} color={colors.foregroundMuted} theme={theme} />
48
49
  <View style={styles.body}>
49
50
  <Text numberOfLines={1} style={styles.title}>
50
51
  <Text style={styles.identifier}>{`${card.identifier} `}</Text>
package/client/ui.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
- import { type ReactNode, useCallback, useMemo } from "react";
3
+ import { type ComponentType, type ReactNode, useCallback, useMemo } from "react";
4
4
  import {
5
5
  ActivityIndicator,
6
6
  Pressable,
@@ -15,6 +15,15 @@ export { Avatar, LabelPill, PriorityIcon, priorityMeta, StateIcon } from "./glyp
15
15
 
16
16
  export type Theme = PluginSurfaceProps["theme"];
17
17
 
18
+ /** A Lucide icon name, or a component such as the plugin icon. */
19
+ export type Glyph = string | ComponentType<{ size: number; color: string; theme: Theme }>;
20
+
21
+ export function GlyphIcon(props: { glyph: Glyph; size: number; color: string; theme: Theme }) {
22
+ const { glyph: Custom, size, color, theme } = props;
23
+ if (typeof Custom === "string") return <Icon name={Custom} size={size} color={color} />;
24
+ return <Custom size={size} color={color} theme={theme} />;
25
+ }
26
+
18
27
  type InteractionState = PressableStateCallbackType & { hovered?: boolean };
19
28
 
20
29
  export function interactive(state: PressableStateCallbackType): boolean {
@@ -43,7 +52,7 @@ function buttonFill(variant: ButtonVariant, colors: Theme["colors"]): string {
43
52
  export interface ButtonProps {
44
53
  theme: Theme;
45
54
  label: string;
46
- icon?: string;
55
+ icon?: Glyph;
47
56
  variant?: ButtonVariant;
48
57
  size?: keyof typeof HEIGHT;
49
58
  disabled?: boolean;
@@ -52,10 +61,10 @@ export interface ButtonProps {
52
61
  onPress(): void;
53
62
  }
54
63
 
55
- function ButtonGlyph(props: { busy: boolean; icon?: string; size: number; color: string }) {
64
+ function ButtonGlyph(props: { busy: boolean; icon?: Glyph; size: number; color: string; theme: Theme }) {
56
65
  if (props.busy) return <ActivityIndicator size="small" color={props.color} />;
57
66
  if (!props.icon) return null;
58
- return <Icon name={props.icon} size={props.size} color={props.color} />;
67
+ return <GlyphIcon glyph={props.icon} size={props.size} color={props.color} theme={props.theme} />;
59
68
  }
60
69
 
61
70
  export function Button({
@@ -103,7 +112,7 @@ export function Button({
103
112
  onPress={onPress}
104
113
  style={pressStyle}
105
114
  >
106
- <ButtonGlyph busy={busy === true} icon={icon} size={size === "xs" ? 14 : 16} color={ink} />
115
+ <ButtonGlyph busy={busy === true} icon={icon} size={size === "xs" ? 14 : 16} color={ink} theme={theme} />
107
116
  <Text numberOfLines={1} style={styles.label}>
108
117
  {label}
109
118
  </Text>
@@ -276,7 +285,7 @@ export function OptionRow(props: {
276
285
 
277
286
  export function EmptyState(props: {
278
287
  theme: Theme;
279
- icon: string;
288
+ icon: Glyph;
280
289
  title: string;
281
290
  detail?: string;
282
291
  tone?: "default" | "danger";
@@ -309,7 +318,7 @@ export function EmptyState(props: {
309
318
  );
310
319
  return (
311
320
  <View style={styles.root}>
312
- <Icon name={props.icon} size={28} color={colors.foregroundMuted} />
321
+ <GlyphIcon glyph={props.icon} size={28} color={colors.foregroundMuted} theme={props.theme} />
313
322
  <Text style={styles.title}>{props.title}</Text>
314
323
  {props.detail ? <Text style={styles.detail}>{props.detail}</Text> : null}
315
324
  {props.children}
package/client/web.ts CHANGED
@@ -2,6 +2,7 @@ import { Platform } from "react-native";
2
2
 
3
3
  // The only module that uses the DOM. React wheel listeners are passive, so they cannot keep
4
4
  // the page from scrolling while the wheel zooms the graph. Off the web each export does nothing.
5
+ // The file picker is a hidden file input, because plugins cannot use native document pickers.
5
6
 
6
7
  interface WheelEventLike {
7
8
  deltaY: number;
@@ -43,3 +44,48 @@ export function listenWheel(
43
44
  node.addEventListener("wheel", listener, { passive: false });
44
45
  return () => node.removeEventListener("wheel", listener);
45
46
  }
47
+
48
+ interface FileLike {
49
+ name: string;
50
+ size: number;
51
+ text(): Promise<string>;
52
+ }
53
+
54
+ interface FileInputLike {
55
+ type: string;
56
+ accept: string;
57
+ files: ArrayLike<FileLike> | null;
58
+ onchange: (() => void) | null;
59
+ click(): void;
60
+ }
61
+
62
+ interface DocumentLike {
63
+ createElement(tag: "input"): FileInputLike;
64
+ }
65
+
66
+ /** True where pickTextFile can open a file picker. */
67
+ export const canPickFiles = Platform.OS === "web";
68
+
69
+ export type PickedFile = { name: string; text: string } | { error: string };
70
+
71
+ /** Opens the browser file picker and calls onPick with the chosen file. Nothing on cancel. */
72
+ export function pickTextFile(accept: string, maxBytes: number, onPick: (file: PickedFile) => void): void {
73
+ const document = (globalThis as { document?: DocumentLike }).document;
74
+ if (!canPickFiles || !document) return;
75
+ const input = document.createElement("input");
76
+ input.type = "file";
77
+ input.accept = accept;
78
+ input.onchange = () => {
79
+ const file = input.files?.[0];
80
+ if (!file) return;
81
+ if (file.size > maxBytes) {
82
+ onPick({ error: `${file.name} is too large. Use a file under ${Math.round(maxBytes / 1000)} KB.` });
83
+ return;
84
+ }
85
+ file.text().then(
86
+ (text) => onPick({ name: file.name, text }),
87
+ () => onPick({ error: `Paseo could not read ${file.name}.` }),
88
+ );
89
+ };
90
+ input.click();
91
+ }
package/index.client.tsx CHANGED
@@ -4,7 +4,9 @@ import { openPluginScreen, registerScreen } from "./client/compat";
4
4
  import { gateAgentPanel } from "./client/access";
5
5
  import { LivePanel } from "./client/live-panel";
6
6
  import { contributeComposerPills } from "./client/pill";
7
+ import { PluginIcon } from "./client/plugin-icon";
7
8
  import { ConnectionSettings } from "./client/settings-connection";
9
+ import { IconSettingsScreen } from "./client/settings-icon";
8
10
  import { LiveSettings } from "./client/settings-live";
9
11
  import { ProjectSettings } from "./client/settings-projects";
10
12
  import { PromptSettings } from "./client/settings-prompts";
@@ -23,6 +25,7 @@ export default function contribute(client: PluginClientContext) {
23
25
  id: SCREEN_ID,
24
26
  title: "Linear",
25
27
  icon: "SquareKanban",
28
+ RowIcon: PluginIcon,
26
29
  Component: LinearScreen,
27
30
  });
28
31
  client.addWorkspacePanel({
@@ -58,6 +61,12 @@ export default function contribute(client: PluginClientContext) {
58
61
  icon: "Radar",
59
62
  Component: LiveSettings,
60
63
  });
64
+ client.addSettingsScreen({
65
+ id: "icon",
66
+ title: "Icon",
67
+ icon: "Shapes",
68
+ Component: IconSettingsScreen,
69
+ });
61
70
  client.addSettingsScreen({
62
71
  id: "projects",
63
72
  title: "Projects",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yannelli/paseo-linear-plugin",
3
- "version": "1.1.0-beta.1",
3
+ "version": "1.1.0-beta.2",
4
4
  "description": "Browse and triage Linear issues in Paseo and start agents from them",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,148 @@
1
+ import { z } from "zod";
2
+
3
+ // The user's own plugin icon. The web app draws it as an image, which never runs scripts or
4
+ // loads files; the checks below also refuse such content. Paint recolors every visible pixel,
5
+ // and Solid fills shapes that only have an outline.
6
+
7
+ export const ICON_PAINTS = ["original", "theme", "accent", "linear", "custom"] as const;
8
+ export type IconPaint = (typeof ICON_PAINTS)[number];
9
+
10
+ /** Linear's brand indigo, the plugin's own color. */
11
+ export const LINEAR_INDIGO = "#5E6AD2";
12
+ export const MAX_SVG_LENGTH = 64_000;
13
+ export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
14
+
15
+ export const IconSettingsSchema = z.object({
16
+ /** Checked SVG markup. Empty uses the built-in icon. */
17
+ svg: z.string().max(MAX_SVG_LENGTH).default(""),
18
+ paint: z.enum(ICON_PAINTS).default("original"),
19
+ /** The color of the custom paint. */
20
+ color: z.string().regex(HEX_COLOR).default(LINEAR_INDIGO),
21
+ /** Fill shapes that only have an outline. */
22
+ solid: z.boolean().default(false),
23
+ });
24
+ export type IconSettings = z.infer<typeof IconSettingsSchema>;
25
+
26
+ const BLOCKED: readonly (readonly [RegExp, string])[] = [
27
+ [/<!DOCTYPE|<!ENTITY/i, "The SVG has a DOCTYPE or entities. Export it again without them."],
28
+ [/<script[\s>/]/i, "The SVG has a script. Use an SVG with shapes only."],
29
+ [/<foreignObject[\s>/]/i, "The SVG has HTML content. Use an SVG with shapes only."],
30
+ [/<[^>]*\son[a-z]+\s*=/i, "The SVG has event handlers. Use an SVG with shapes only."],
31
+ [/[\s:]href\s*=\s*["']\s*(?!#)/i, "The SVG links to other files or images. Use an SVG that has all its shapes inside."],
32
+ [/url\(\s*["']?\s*(?!#)/i, "The SVG uses files outside it. Use an SVG that has all its shapes inside."],
33
+ [/@import/i, "The SVG imports styles from another file."],
34
+ ];
35
+
36
+ const ROOT_TAG = /^<svg\b[^>]*>/i;
37
+ const NUMBER = String.raw`(-?[\d.]+(?:e[-+]?\d+)?)`;
38
+ const VIEW_BOX = new RegExp(String.raw`\sviewBox\s*=\s*["']\s*${NUMBER}[\s,]+${NUMBER}[\s,]+${NUMBER}[\s,]+${NUMBER}\s*["']`, "i");
39
+
40
+ const attribute = (tag: string, name: string) =>
41
+ new RegExp(String.raw`\s${name}\s*=\s*["']([^"']*)["']`, "i").exec(tag)?.[1]?.trim() ?? null;
42
+ const withoutAttribute = (tag: string, name: string) =>
43
+ tag.replace(new RegExp(String.raw`\s${name}\s*=\s*(?:"[^"]*"|'[^']*')`, "gi"), "");
44
+
45
+ /** The root's viewBox as x, y, width, and height, or null without a usable one. */
46
+ export function viewBoxOf(svg: string): [number, number, number, number] | null {
47
+ const root = ROOT_TAG.exec(svg)?.[0] ?? "";
48
+ const box = VIEW_BOX.exec(root)?.slice(1, 5).map(Number);
49
+ if (!box || box.some((value) => !Number.isFinite(value))) return null;
50
+ const [x = 0, y = 0, width = 0, height = 0] = box;
51
+ return width > 0 && height > 0 ? [x, y, width, height] : null;
52
+ }
53
+
54
+ /** Checks pasted or uploaded SVG and returns it ready to save, or why it cannot be used. */
55
+ export function parseIconSvg(text: string): { svg: string } | { error: string } {
56
+ const svg = text
57
+ .replace(/^\uFEFF/, "")
58
+ .replace(/<\?[\s\S]*?\?>/g, "")
59
+ .replace(/<!--[\s\S]*?-->/g, "")
60
+ .trim();
61
+ if (!svg) return { error: "Paste SVG markup or choose an SVG file." };
62
+ if (svg.length > MAX_SVG_LENGTH) {
63
+ return { error: `The SVG is too large. Use one under ${Math.round(MAX_SVG_LENGTH / 1000)} KB.` };
64
+ }
65
+ if (!/^<svg[\s>]/i.test(svg) || !/<\/svg>$/i.test(svg)) {
66
+ return { error: "This is not an SVG. It must start with <svg and end with </svg>." };
67
+ }
68
+ for (const [pattern, error] of BLOCKED) if (pattern.test(svg)) return { error };
69
+ const root = ROOT_TAG.exec(svg)?.[0] ?? "";
70
+ let next = root;
71
+ if (!viewBoxOf(svg)) {
72
+ const width = Number(/^([\d.]+)(?:px)?$/.exec(attribute(root, "width") ?? "")?.[1]);
73
+ const height = Number(/^([\d.]+)(?:px)?$/.exec(attribute(root, "height") ?? "")?.[1]);
74
+ if (!(width > 0 && height > 0)) return { error: "The SVG needs a viewBox, or a width and height in pixels." };
75
+ next = withoutAttribute(next, "viewBox").replace(/^<svg/i, `<svg viewBox="0 0 ${width} ${height}"`);
76
+ }
77
+ // The image scales to its box from the viewBox, so a fixed size would only crop it.
78
+ next = withoutAttribute(withoutAttribute(next, "width"), "height");
79
+ if (!/\sxmlns\s*=/.test(next)) next = next.replace(/^<svg/i, '<svg xmlns="http://www.w3.org/2000/svg"');
80
+ return { svg: next + svg.slice(root.length) };
81
+ }
82
+
83
+ /** Keeps a color inside an attribute value; anything else becomes gray. */
84
+ export function safeColor(color: string): string {
85
+ return /^[#a-z0-9(),.%\s-]+$/i.test(color) ? color.trim() : "#888888";
86
+ }
87
+
88
+ const FILL_NONE = /(?<![\w-])(fill\s*:\s*)none\b/gi;
89
+
90
+ /** Fills shapes that only have an outline, with their stroke color or the icon color. */
91
+ export function fillOutlines(svg: string): string {
92
+ const tags = svg.replace(/<([a-z][\w:.-]*)(\s[^>]*?)?(\/?)>/gi, (tag, name: string, attrs: string | undefined, close: string) => {
93
+ if (!attrs) return tag;
94
+ const stroke = attribute(attrs, "stroke") ?? /(?<![\w-])stroke\s*:\s*([^;"']+)/i.exec(attrs)?.[1]?.trim();
95
+ const fill = stroke && stroke !== "none" ? stroke : "currentColor";
96
+ const next = attrs.replace(/(\sfill\s*=\s*["'])\s*none\s*(["'])/gi, `$1${fill}$2`).replace(FILL_NONE, `$1${fill}`);
97
+ return `<${name}${next}${close}>`;
98
+ });
99
+ return tags.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_all, open: string, css: string, end: string) =>
100
+ `${open}${css.replace(FILL_NONE, "$1currentColor")}${end}`,
101
+ );
102
+ }
103
+
104
+ const TINT_ID = "paseo-linear-tint";
105
+
106
+ /** Paints every visible pixel one color and keeps its transparency. */
107
+ export function tint(svg: string, color: string): string {
108
+ const [x, y, width, height] = viewBoxOf(svg) ?? [0, 0, 24, 24];
109
+ const filter =
110
+ `<filter id="${TINT_ID}" filterUnits="userSpaceOnUse" x="${x - width}" y="${y - height}" width="${width * 3}" height="${height * 3}" color-interpolation-filters="sRGB">` +
111
+ `<feFlood flood-color="${safeColor(color)}"/><feComposite in2="SourceAlpha" operator="in"/></filter>`;
112
+ return svg
113
+ .replace(ROOT_TAG, (root) => `${root}<defs>${filter}</defs><g filter="url(#${TINT_ID})">`)
114
+ .replace(/<\/svg>$/i, "</g></svg>");
115
+ }
116
+
117
+ export interface IconLook {
118
+ /** The color `currentColor` takes: the color Paseo gives the icon. */
119
+ ink: string;
120
+ /** One color for the whole icon, or null to keep its colors. */
121
+ paint: string | null;
122
+ solid: boolean;
123
+ }
124
+
125
+ /** The SVG to draw for saved markup, or null when it no longer passes the checks. */
126
+ export function iconMarkup(saved: string, look: IconLook): string | null {
127
+ const parsed = parseIconSvg(saved);
128
+ if ("error" in parsed) return null;
129
+ const filled = look.solid ? fillOutlines(parsed.svg) : parsed.svg;
130
+ const inked = filled.replace(/currentColor/gi, safeColor(look.ink));
131
+ return look.paint ? tint(inked, look.paint) : inked;
132
+ }
133
+
134
+ /** The one color a paint gives, or null for the icon's own colors. */
135
+ export function paintColor(icon: IconSettings, colors: { ink: string; accent: string }): string | null {
136
+ switch (icon.paint) {
137
+ case "original":
138
+ return null;
139
+ case "theme":
140
+ return colors.ink;
141
+ case "accent":
142
+ return colors.accent;
143
+ case "linear":
144
+ return LINEAR_INDIGO;
145
+ case "custom":
146
+ return icon.color;
147
+ }
148
+ }
@@ -1,5 +1,6 @@
1
1
  import { defineRpc, defineSettings } from "@getpaseo/plugin";
2
2
  import { z } from "zod";
3
+ import { IconSettingsSchema } from "./custom-icon";
3
4
 
4
5
  export const AGENT_ACTIONS = ["implement", "review"] as const;
5
6
  export type AgentAction = (typeof AGENT_ACTIONS)[number];
@@ -101,6 +102,8 @@ export const linearSettings = defineSettings({
101
102
  })
102
103
  .prefault({}),
103
104
  projects: z.array(ProjectConfigSchema).default([]),
105
+ /** The icon of pills and other plugin places: built-in or the user's SVG. */
106
+ icon: IconSettingsSchema.prefault({}),
104
107
  }),
105
108
  });
106
109