Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d013354
Add ImageDef/PngDef with format components and PNG metadata extraction
lukemelia Feb 2, 2026
036c156
Fix prettier formatting in png-image-def-test
lukemelia Feb 3, 2026
24cf925
Remove duplicate Uint8Array check in test adapter
lukemelia Feb 3, 2026
7997465
Fix tests to use valid PNG bytes for PngDef extraction
lukemelia Feb 3, 2026
bddde4c
Fall back to client-side search when server-side cross-realm query ha…
lukemelia Feb 3, 2026
b9ed547
Use .txt files in FileDef render tests to avoid PngDef behavior
lukemelia Feb 3, 2026
64a5e57
Add test for authenticated image rendering in browser
lukemelia Feb 3, 2026
00cf4dc
Add PngDefPlayground implementation with image gallery and format com…
lukemelia Feb 3, 2026
e531f08
add invite user to room command (#3946)
tintinthong Feb 4, 2026
8a4f09b
When inserting a new subscription cycle, read the actual subscription…
jurgenwerk Jan 26, 2026
78dec90
Prefer an error over fallback to incorrect values
jurgenwerk Jan 27, 2026
5522aa2
Adjust tests
jurgenwerk Jan 28, 2026
ab505db
Improve error msg
jurgenwerk Feb 4, 2026
97dcef8
Change realm download to not use in-memory blob (#3944)
backspace Feb 4, 2026
c815110
Add MCP server configuration for Linear and Chrome DevTools
lukemelia Feb 4, 2026
c14f12e
Implement JpgImageDef with JPEG dimension extraction and tests
lukemelia Feb 4, 2026
a2bb3a0
Implement SvgImageDef with SVG dimension extraction and tests
lukemelia Feb 4, 2026
b57d560
Implement GifImageDef with GIF dimension extraction and tests
lukemelia Feb 4, 2026
6a5cf8a
Implement WebpImageDef with WebP dimension extraction and tests
lukemelia Feb 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"mcpServers": {
"linear": {
"type": "http",
"url": "https://mcp.linear.app/mcp"
},
"chrome-devtools": {
"type": "stdio",
"command": "npx",
"args": ["chrome-devtools-mcp@latest"],
"env": {}
}
}
}
6 changes: 6 additions & 0 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,12 @@ export type GetSearchResourceFuncOpts = {
cards: CardDef[];
searchURL?: string;
realms?: string[];
queryErrors?: Array<{
realm: string;
type: string;
message: string;
status?: number;
}>;
};
};
export type GetSearchResourceFunc<T extends CardDef | FileDef = CardDef> = (
Expand Down
19 changes: 18 additions & 1 deletion packages/base/command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,11 @@ export class CreateAIAssistantRoomResult extends CardDef {
@field roomId = contains(StringField);
}

export class InviteUserToRoomInput extends CardDef {
@field roomId = contains(StringField);
@field userId = contains(StringField);
}

export class RegisterBotInput extends CardDef {
@field username = contains(StringField);
}
Expand Down Expand Up @@ -363,7 +368,7 @@ export class ListingInstallResult extends CardDef {
export class CreateListingPRInput extends CardDef {
@field roomId = contains(StringField);
@field realm = contains(RealmField);
@field listing = linksTo(CardDef);
@field listingId = contains(StringField);
}

export class CreateListingPRResult extends CardDef {
Expand All @@ -374,6 +379,12 @@ export class CreateListingPRResult extends CardDef {
@field prNumber = contains(NumberField);
}

export class CreateListingPRRequestInput extends CardDef {
@field roomId = contains(StringField);
@field realm = contains(RealmField);
@field listingId = contains(StringField);
}

export class ListingCreateInput extends CardDef {
@field openCardId = contains(StringField);
@field codeRef = contains(CodeRefField);
Expand Down Expand Up @@ -419,6 +430,12 @@ export class GetEventsFromRoomResult extends CardDef {
@field matrixEvents = containsMany(JsonField);
}

export class SendBotTriggerEventInput extends CardDef {
@field roomId = contains(StringField);
@field type = contains(StringField);
@field input = contains(JsonField);
}

export class PreviewFormatInput extends CardDef {
@field cardId = contains(StringField);
@field format = contains(StringField);
Expand Down
30 changes: 30 additions & 0 deletions packages/base/gif-image-def.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { byteStreamToUint8Array } from '@cardstack/runtime-common';
import { ImageDef } from './image-file-def';
import { type ByteStream, type SerializedFile } from './file-api';
import { extractGifDimensions } from './gif-meta-extractor';

export class GifDef extends ImageDef {
static displayName = 'GIF Image';

static async extractAttributes(
url: string,
getStream: () => Promise<ByteStream>,
options: { contentHash?: string } = {},
): Promise<SerializedFile<{ width: number; height: number }>> {
let bytesPromise: Promise<Uint8Array> | undefined;
let memoizedStream = async () => {
bytesPromise ??= byteStreamToUint8Array(await getStream());
return bytesPromise;
};

let base = await super.extractAttributes(url, memoizedStream, options);
let bytes = await memoizedStream();
let { width, height } = extractGifDimensions(bytes);

return {
...base,
width,
height,
};
}
}
45 changes: 45 additions & 0 deletions packages/base/gif-meta-extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { FileContentMismatchError } from './file-api';

// GIF files start with either "GIF87a" or "GIF89a" (6 bytes)
const GIF87A_SIGNATURE = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]);
const GIF89A_SIGNATURE = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);

// Minimum bytes needed: 6 (signature) + 4 (width + height)
const MIN_BYTES = 10;

function validateGifSignature(bytes: Uint8Array): void {
if (bytes.length < 6) {
throw new FileContentMismatchError(
'File is too small to be a valid GIF image',
);
}

let isGif87a = GIF87A_SIGNATURE.every((b, i) => bytes[i] === b);
let isGif89a = GIF89A_SIGNATURE.every((b, i) => bytes[i] === b);

if (!isGif87a && !isGif89a) {
throw new FileContentMismatchError(
'File does not have a valid GIF signature',
);
}
}

export function extractGifDimensions(bytes: Uint8Array): {
width: number;
height: number;
} {
validateGifSignature(bytes);

if (bytes.length < MIN_BYTES) {
throw new FileContentMismatchError(
'GIF file is too small to contain image dimensions',
);
}

// Width is at bytes 6-7, height at 8-9 (little-endian uint16)
let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let width = view.getUint16(6, true);
let height = view.getUint16(8, true);

return { width, height };
}
207 changes: 207 additions & 0 deletions packages/base/image-file-def.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import NumberField from './number';
import { BaseDefComponent, Component, contains, field } from './card-api';
import { FileDef } from './file-api';

class Isolated extends Component<typeof ImageDef> {
<template>
<div class='image-isolated'>
{{#if @model.url}}
<img
class='image-isolated__img'
src={{@model.url}}
alt={{@model.name}}
width={{@model.width}}
height={{@model.height}}
/>
<footer class='image-isolated__meta'>
<span class='image-isolated__name'>{{@model.name}}</span>
{{#if @model.width}}
<span class='image-isolated__dimensions'>{{@model.width}}
&times;
{{@model.height}}px</span>
{{/if}}
</footer>
{{else}}
<p class='image-isolated__empty'>{{@model.name}}</p>
{{/if}}
</div>
<style scoped>
.image-isolated {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--boxel-sp-xs);
max-width: 100%;
}

.image-isolated__img {
max-width: 100%;
height: auto;
border-radius: var(--boxel-radius-sm);
}

.image-isolated__meta {
display: flex;
align-items: baseline;
gap: var(--boxel-sp-xs);
color: var(--boxel-600);
font-size: var(--boxel-font-sm);
}

.image-isolated__name {
font-weight: 600;
color: var(--boxel-900);
}

.image-isolated__empty {
color: var(--boxel-600);
margin: 0;
}
</style>
</template>
}

class Atom extends Component<typeof ImageDef> {
<template>
<div class='image-atom'>
{{#if @model.url}}
<img
class='image-atom__img'
src={{@model.url}}
alt={{@model.name}}
/>
{{/if}}
<span class='image-atom__name'>{{@model.name}}</span>
</div>
<style scoped>
.image-atom {
display: inline-flex;
align-items: center;
gap: var(--boxel-sp-xs);
min-width: 0;
}

.image-atom__img {
width: 20px;
height: 20px;
object-fit: cover;
border-radius: var(--boxel-radius-xs);
flex-shrink: 0;
}

.image-atom__name {
color: var(--boxel-900);
font-size: var(--boxel-font-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
</template>
}

class Embedded extends Component<typeof ImageDef> {
<template>
<div class='image-embedded'>
{{#if @model.url}}
<img
class='image-embedded__img'
src={{@model.url}}
alt={{@model.name}}
/>
{{else}}
<p class='image-embedded__empty'>{{@model.name}}</p>
{{/if}}
</div>
<style scoped>
.image-embedded {
width: 100%;
}

.image-embedded__img {
display: block;
width: 100%;
height: auto;
border-radius: var(--boxel-radius-sm);
}

.image-embedded__empty {
color: var(--boxel-600);
margin: 0;
}
</style>
</template>
}

class Fitted extends Component<typeof ImageDef> {
get backgroundImageStyle() {
if (this.args.model.url) {
return `background-image: url(${this.args.model.url});`;
}
return undefined;
}

<template>
<div class='image-fitted'>
{{#if @model.url}}
<div
class='image-fitted__bg'
style={{this.backgroundImageStyle}}
role='img'
aria-label={{@model.name}}
></div>
{{else}}
<div class='image-fitted__placeholder'>
<span class='image-fitted__name'>{{@model.name}}</span>
</div>
{{/if}}
</div>
<style scoped>
.image-fitted {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}

.image-fitted__bg {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}

.image-fitted__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--boxel-100);
color: var(--boxel-600);
font-size: var(--boxel-font-sm);
}

.image-fitted__name {
font-size: var(--boxel-font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
</style>
</template>
}

export class ImageDef extends FileDef {
static displayName = 'Image';

@field width = contains(NumberField);
@field height = contains(NumberField);

static isolated: BaseDefComponent = Isolated;
static embedded: BaseDefComponent = Embedded;
static atom: BaseDefComponent = Atom;
static fitted: BaseDefComponent = Fitted;
}
30 changes: 30 additions & 0 deletions packages/base/jpg-image-def.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { byteStreamToUint8Array } from '@cardstack/runtime-common';
import { ImageDef } from './image-file-def';
import { type ByteStream, type SerializedFile } from './file-api';
import { extractJpgDimensions } from './jpg-meta-extractor';

export class JpgDef extends ImageDef {
static displayName = 'JPEG Image';

static async extractAttributes(
url: string,
getStream: () => Promise<ByteStream>,
options: { contentHash?: string } = {},
): Promise<SerializedFile<{ width: number; height: number }>> {
let bytesPromise: Promise<Uint8Array> | undefined;
let memoizedStream = async () => {
bytesPromise ??= byteStreamToUint8Array(await getStream());
return bytesPromise;
};

let base = await super.extractAttributes(url, memoizedStream, options);
let bytes = await memoizedStream();
let { width, height } = extractJpgDimensions(bytes);

return {
...base,
width,
height,
};
}
}
Loading
Loading