@vellumai/cli 0.12.3-dev.202609222214.c4d457d → 0.12.3-dev.202609222313.c4916bb

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.
@@ -67,8 +67,31 @@ describe("notificationAvatarDiscHex", () => {
67
67
  });
68
68
 
69
69
  describe("notificationAvatarSvg", () => {
70
+ test("fills the entire circular crop with a custom image", () => {
71
+ const svg = notificationAvatarSvg({
72
+ kind: "image",
73
+ innerPngBase64: PNG_BASE64,
74
+ accentHex: "#E9642F",
75
+ });
76
+
77
+ expect(attributes("image", svg)).toMatchObject({
78
+ x: "0",
79
+ y: "0",
80
+ width: "256",
81
+ height: "256",
82
+ preserveAspectRatio: "xMidYMid slice",
83
+ "clip-path": "url(#notification-avatar-disc)",
84
+ });
85
+ expect(allAttributes("circle", svg)[1]).toEqual({
86
+ cx: "128",
87
+ cy: "128",
88
+ r: "128",
89
+ });
90
+ });
91
+
70
92
  test("draws one disc and one inset avatar at the default size", () => {
71
93
  const svg = notificationAvatarSvg({
94
+ kind: "character",
72
95
  innerPngBase64: PNG_BASE64,
73
96
  accentHex: "#E9642F",
74
97
  });
@@ -100,6 +123,7 @@ describe("notificationAvatarSvg", () => {
100
123
  // upload fill the square instead of letterboxing is the pair of attributes
101
124
  // asserted here.
102
125
  const svg = notificationAvatarSvg({
126
+ kind: "character",
103
127
  innerPngBase64: PNG_BASE64,
104
128
  accentHex: "#E9642F",
105
129
  });
@@ -121,6 +145,7 @@ describe("notificationAvatarSvg", () => {
121
145
 
122
146
  test("scales the geometry to a custom size", () => {
123
147
  const svg = notificationAvatarSvg({
148
+ kind: "character",
124
149
  innerPngBase64: PNG_BASE64,
125
150
  accentHex: "#E9642F",
126
151
  size: 100,
@@ -142,6 +167,7 @@ describe("notificationAvatarSvg", () => {
142
167
 
143
168
  test("carries a non-PNG inner raster with its own media type", () => {
144
169
  const svg = notificationAvatarSvg({
170
+ kind: "character",
145
171
  innerPngBase64: PNG_BASE64,
146
172
  innerMediaType: "image/jpeg",
147
173
  accentHex: "#E9642F",
@@ -154,6 +180,7 @@ describe("notificationAvatarSvg", () => {
154
180
 
155
181
  test("paints the fallback disc when there is no accent", () => {
156
182
  const svg = notificationAvatarSvg({
183
+ kind: "character",
157
184
  innerPngBase64: PNG_BASE64,
158
185
  accentHex: null,
159
186
  });
@@ -164,8 +191,21 @@ describe("notificationAvatarSvg", () => {
164
191
  });
165
192
 
166
193
  describe("notificationAvatarGeometry", () => {
194
+ test("custom images reach the disc edge at any output size", () => {
195
+ expect(notificationAvatarGeometry("image")).toEqual({
196
+ radius: 128,
197
+ offset: 0,
198
+ inner: 256,
199
+ });
200
+ expect(notificationAvatarGeometry("image", 100)).toEqual({
201
+ radius: 50,
202
+ offset: 0,
203
+ inner: 100,
204
+ });
205
+ });
206
+
167
207
  test("derives the disc, the border and the avatar edge from the size", () => {
168
- expect(notificationAvatarGeometry(100)).toEqual({
208
+ expect(notificationAvatarGeometry("character", 100)).toEqual({
169
209
  radius: 50,
170
210
  offset: 11,
171
211
  inner: 78,
@@ -173,15 +213,19 @@ describe("notificationAvatarGeometry", () => {
173
213
  });
174
214
 
175
215
  test("measures the default size when given none", () => {
176
- expect(notificationAvatarGeometry()).toEqual(
177
- notificationAvatarGeometry(NOTIFICATION_AVATAR_SIZE),
216
+ expect(notificationAvatarGeometry("character")).toEqual(
217
+ notificationAvatarGeometry("character", NOTIFICATION_AVATAR_SIZE),
178
218
  );
179
219
  });
180
220
 
181
221
  test("is the geometry the SVG is drawn with", () => {
182
222
  const size = 100;
183
- const { radius, offset, inner } = notificationAvatarGeometry(size);
223
+ const { radius, offset, inner } = notificationAvatarGeometry(
224
+ "character",
225
+ size,
226
+ );
184
227
  const svg = notificationAvatarSvg({
228
+ kind: "character",
185
229
  innerPngBase64: PNG_BASE64,
186
230
  accentHex: null,
187
231
  size,
@@ -15,11 +15,12 @@
15
15
  */
16
16
 
17
17
  import { isAvatarAccentHex } from "./accent.js";
18
+ import type { AvatarKind } from "./manifest.js";
18
19
 
19
20
  /** Output edge in pixels: large enough for an iOS notification thumbnail. */
20
21
  export const NOTIFICATION_AVATAR_SIZE = 256;
21
22
 
22
- /** Free disc on each side, as a fraction of the edge; the avatar gets the rest. */
23
+ /** Free disc on each side of a character avatar, as a fraction of the edge. */
23
24
  export const NOTIFICATION_AVATAR_INSET = 0.11;
24
25
 
25
26
  /** The disc when the assistant has no accent. */
@@ -48,7 +49,7 @@ export const NOTIFICATION_AVATAR_MAX_LOCAL_BYTES = 512 * 1024;
48
49
  * Bumped whenever the drawing above changes, so a sync keyed on it re-uploads
49
50
  * a disc rendered by an older spec even when the avatar itself is unchanged.
50
51
  */
51
- export const NOTIFICATION_AVATAR_SPEC_VERSION = 1;
52
+ export const NOTIFICATION_AVATAR_SPEC_VERSION = 2;
52
53
 
53
54
  /** The id the inner raster's clip path is referenced by inside the document. */
54
55
  const CLIP_ID = "notification-avatar-disc";
@@ -74,24 +75,26 @@ export function notificationAvatarDiscHex(accentHex: string | null): string {
74
75
 
75
76
  /** Raster formats an `<image>` href carries here; what resvg and a canvas both decode. */
76
77
  export type NotificationAvatarMediaType =
77
- | "image/png"
78
- | "image/jpeg"
79
- | "image/gif";
78
+ "image/png" | "image/jpeg" | "image/gif";
80
79
 
81
80
  /**
82
81
  * The three measurements the drawing derives from the edge: the disc's radius,
83
82
  * which is also its centre; the free border around the avatar; and the
84
83
  * avatar's own edge. Every rasterizer reads them from here, so the daemon's
85
84
  * SVG and the desktop canvas cannot drift.
85
+ * Custom images fill the disc; character avatars retain space around their
86
+ * silhouette.
86
87
  */
87
88
  export function notificationAvatarGeometry(
89
+ kind: Exclude<AvatarKind, "none">,
88
90
  size: number = NOTIFICATION_AVATAR_SIZE,
89
91
  ): { radius: number; offset: number; inner: number } {
90
- const offset = size * NOTIFICATION_AVATAR_INSET;
92
+ const offset = kind === "character" ? size * NOTIFICATION_AVATAR_INSET : 0;
91
93
  return { radius: size / 2, offset, inner: size - 2 * offset };
92
94
  }
93
95
 
94
96
  export interface NotificationAvatarSvgOptions {
97
+ kind: Exclude<AvatarKind, "none">;
95
98
  /** The avatar raster to draw inside the disc, base64 with no data prefix. */
96
99
  innerPngBase64: string;
97
100
  /** What `innerPngBase64` holds; PNG unless an upload arrived as a JPEG or a GIF. */
@@ -107,7 +110,7 @@ function px(value: number): string {
107
110
 
108
111
  /**
109
112
  * The notification avatar as an SVG document: a filled disc with the avatar
110
- * drawn inset into it.
113
+ * filling it for custom images or inset for character avatars.
111
114
  *
112
115
  * The inner raster is cover-cropped (`xMidYMid slice`), matching what the web
113
116
  * canvas does, so a portrait or landscape upload fills the square instead of
@@ -119,12 +122,13 @@ function px(value: number): string {
119
122
  * rasterizer is known to render it.
120
123
  */
121
124
  export function notificationAvatarSvg({
125
+ kind,
122
126
  innerPngBase64,
123
127
  innerMediaType = "image/png",
124
128
  accentHex,
125
129
  size = NOTIFICATION_AVATAR_SIZE,
126
130
  }: NotificationAvatarSvgOptions): string {
127
- const { radius, offset, inner } = notificationAvatarGeometry(size);
131
+ const { radius, offset, inner } = notificationAvatarGeometry(kind, size);
128
132
  const href = `data:${innerMediaType};base64,${innerPngBase64}`;
129
133
  const disc = `cx="${px(radius)}" cy="${px(radius)}" r="${px(radius)}"`;
130
134
  return (
@@ -67,8 +67,31 @@ describe("notificationAvatarDiscHex", () => {
67
67
  });
68
68
 
69
69
  describe("notificationAvatarSvg", () => {
70
+ test("fills the entire circular crop with a custom image", () => {
71
+ const svg = notificationAvatarSvg({
72
+ kind: "image",
73
+ innerPngBase64: PNG_BASE64,
74
+ accentHex: "#E9642F",
75
+ });
76
+
77
+ expect(attributes("image", svg)).toMatchObject({
78
+ x: "0",
79
+ y: "0",
80
+ width: "256",
81
+ height: "256",
82
+ preserveAspectRatio: "xMidYMid slice",
83
+ "clip-path": "url(#notification-avatar-disc)",
84
+ });
85
+ expect(allAttributes("circle", svg)[1]).toEqual({
86
+ cx: "128",
87
+ cy: "128",
88
+ r: "128",
89
+ });
90
+ });
91
+
70
92
  test("draws one disc and one inset avatar at the default size", () => {
71
93
  const svg = notificationAvatarSvg({
94
+ kind: "character",
72
95
  innerPngBase64: PNG_BASE64,
73
96
  accentHex: "#E9642F",
74
97
  });
@@ -100,6 +123,7 @@ describe("notificationAvatarSvg", () => {
100
123
  // upload fill the square instead of letterboxing is the pair of attributes
101
124
  // asserted here.
102
125
  const svg = notificationAvatarSvg({
126
+ kind: "character",
103
127
  innerPngBase64: PNG_BASE64,
104
128
  accentHex: "#E9642F",
105
129
  });
@@ -121,6 +145,7 @@ describe("notificationAvatarSvg", () => {
121
145
 
122
146
  test("scales the geometry to a custom size", () => {
123
147
  const svg = notificationAvatarSvg({
148
+ kind: "character",
124
149
  innerPngBase64: PNG_BASE64,
125
150
  accentHex: "#E9642F",
126
151
  size: 100,
@@ -142,6 +167,7 @@ describe("notificationAvatarSvg", () => {
142
167
 
143
168
  test("carries a non-PNG inner raster with its own media type", () => {
144
169
  const svg = notificationAvatarSvg({
170
+ kind: "character",
145
171
  innerPngBase64: PNG_BASE64,
146
172
  innerMediaType: "image/jpeg",
147
173
  accentHex: "#E9642F",
@@ -154,6 +180,7 @@ describe("notificationAvatarSvg", () => {
154
180
 
155
181
  test("paints the fallback disc when there is no accent", () => {
156
182
  const svg = notificationAvatarSvg({
183
+ kind: "character",
157
184
  innerPngBase64: PNG_BASE64,
158
185
  accentHex: null,
159
186
  });
@@ -164,8 +191,21 @@ describe("notificationAvatarSvg", () => {
164
191
  });
165
192
 
166
193
  describe("notificationAvatarGeometry", () => {
194
+ test("custom images reach the disc edge at any output size", () => {
195
+ expect(notificationAvatarGeometry("image")).toEqual({
196
+ radius: 128,
197
+ offset: 0,
198
+ inner: 256,
199
+ });
200
+ expect(notificationAvatarGeometry("image", 100)).toEqual({
201
+ radius: 50,
202
+ offset: 0,
203
+ inner: 100,
204
+ });
205
+ });
206
+
167
207
  test("derives the disc, the border and the avatar edge from the size", () => {
168
- expect(notificationAvatarGeometry(100)).toEqual({
208
+ expect(notificationAvatarGeometry("character", 100)).toEqual({
169
209
  radius: 50,
170
210
  offset: 11,
171
211
  inner: 78,
@@ -173,15 +213,19 @@ describe("notificationAvatarGeometry", () => {
173
213
  });
174
214
 
175
215
  test("measures the default size when given none", () => {
176
- expect(notificationAvatarGeometry()).toEqual(
177
- notificationAvatarGeometry(NOTIFICATION_AVATAR_SIZE),
216
+ expect(notificationAvatarGeometry("character")).toEqual(
217
+ notificationAvatarGeometry("character", NOTIFICATION_AVATAR_SIZE),
178
218
  );
179
219
  });
180
220
 
181
221
  test("is the geometry the SVG is drawn with", () => {
182
222
  const size = 100;
183
- const { radius, offset, inner } = notificationAvatarGeometry(size);
223
+ const { radius, offset, inner } = notificationAvatarGeometry(
224
+ "character",
225
+ size,
226
+ );
184
227
  const svg = notificationAvatarSvg({
228
+ kind: "character",
185
229
  innerPngBase64: PNG_BASE64,
186
230
  accentHex: null,
187
231
  size,
@@ -15,11 +15,12 @@
15
15
  */
16
16
 
17
17
  import { isAvatarAccentHex } from "./accent.js";
18
+ import type { AvatarKind } from "./manifest.js";
18
19
 
19
20
  /** Output edge in pixels: large enough for an iOS notification thumbnail. */
20
21
  export const NOTIFICATION_AVATAR_SIZE = 256;
21
22
 
22
- /** Free disc on each side, as a fraction of the edge; the avatar gets the rest. */
23
+ /** Free disc on each side of a character avatar, as a fraction of the edge. */
23
24
  export const NOTIFICATION_AVATAR_INSET = 0.11;
24
25
 
25
26
  /** The disc when the assistant has no accent. */
@@ -48,7 +49,7 @@ export const NOTIFICATION_AVATAR_MAX_LOCAL_BYTES = 512 * 1024;
48
49
  * Bumped whenever the drawing above changes, so a sync keyed on it re-uploads
49
50
  * a disc rendered by an older spec even when the avatar itself is unchanged.
50
51
  */
51
- export const NOTIFICATION_AVATAR_SPEC_VERSION = 1;
52
+ export const NOTIFICATION_AVATAR_SPEC_VERSION = 2;
52
53
 
53
54
  /** The id the inner raster's clip path is referenced by inside the document. */
54
55
  const CLIP_ID = "notification-avatar-disc";
@@ -74,24 +75,26 @@ export function notificationAvatarDiscHex(accentHex: string | null): string {
74
75
 
75
76
  /** Raster formats an `<image>` href carries here; what resvg and a canvas both decode. */
76
77
  export type NotificationAvatarMediaType =
77
- | "image/png"
78
- | "image/jpeg"
79
- | "image/gif";
78
+ "image/png" | "image/jpeg" | "image/gif";
80
79
 
81
80
  /**
82
81
  * The three measurements the drawing derives from the edge: the disc's radius,
83
82
  * which is also its centre; the free border around the avatar; and the
84
83
  * avatar's own edge. Every rasterizer reads them from here, so the daemon's
85
84
  * SVG and the desktop canvas cannot drift.
85
+ * Custom images fill the disc; character avatars retain space around their
86
+ * silhouette.
86
87
  */
87
88
  export function notificationAvatarGeometry(
89
+ kind: Exclude<AvatarKind, "none">,
88
90
  size: number = NOTIFICATION_AVATAR_SIZE,
89
91
  ): { radius: number; offset: number; inner: number } {
90
- const offset = size * NOTIFICATION_AVATAR_INSET;
92
+ const offset = kind === "character" ? size * NOTIFICATION_AVATAR_INSET : 0;
91
93
  return { radius: size / 2, offset, inner: size - 2 * offset };
92
94
  }
93
95
 
94
96
  export interface NotificationAvatarSvgOptions {
97
+ kind: Exclude<AvatarKind, "none">;
95
98
  /** The avatar raster to draw inside the disc, base64 with no data prefix. */
96
99
  innerPngBase64: string;
97
100
  /** What `innerPngBase64` holds; PNG unless an upload arrived as a JPEG or a GIF. */
@@ -107,7 +110,7 @@ function px(value: number): string {
107
110
 
108
111
  /**
109
112
  * The notification avatar as an SVG document: a filled disc with the avatar
110
- * drawn inset into it.
113
+ * filling it for custom images or inset for character avatars.
111
114
  *
112
115
  * The inner raster is cover-cropped (`xMidYMid slice`), matching what the web
113
116
  * canvas does, so a portrait or landscape upload fills the square instead of
@@ -119,12 +122,13 @@ function px(value: number): string {
119
122
  * rasterizer is known to render it.
120
123
  */
121
124
  export function notificationAvatarSvg({
125
+ kind,
122
126
  innerPngBase64,
123
127
  innerMediaType = "image/png",
124
128
  accentHex,
125
129
  size = NOTIFICATION_AVATAR_SIZE,
126
130
  }: NotificationAvatarSvgOptions): string {
127
- const { radius, offset, inner } = notificationAvatarGeometry(size);
131
+ const { radius, offset, inner } = notificationAvatarGeometry(kind, size);
128
132
  const href = `data:${innerMediaType};base64,${innerPngBase64}`;
129
133
  const disc = `cx="${px(radius)}" cy="${px(radius)}" r="${px(radius)}"`;
130
134
  return (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.12.3-dev.202609222214.c4d457d",
3
+ "version": "0.12.3-dev.202609222313.c4916bb",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {