Skip to content

Commit 8f44821

Browse files
lucaslylclaude
andauthored
Aspect Ratio Field Listing (#649)
linear: https://linear.app/cardstack/issue/ECO-519/aspect-ratio-field-listing ## summary: Package the Aspect Ratio field as a catalog listing, built for the AI Image Card use case (ECO-510) so any card needing an aspect-ratio input can reuse the visual tile picker instead of a plain dropdown. - Add example playground AspectRatioExample (Interactive / Preview / Available ratios / Usage), matching the rating|slider|quantity pattern - Add example instance - Wire the example, code-snippet Spec, and Screenshot into the FieldListing Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 418ef62 commit 8f44821

6 files changed

Lines changed: 491 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
{
2+
"data": {
3+
"meta": {
4+
"adoptsFrom": {
5+
"name": "FieldListing",
6+
"module": "@cardstack/catalog/catalog-app/listing/listing"
7+
}
8+
},
9+
"type": "card",
10+
"attributes": {
11+
"name": "Aspect Ratio",
12+
"summary": "A string field for picking an image aspect ratio. Its custom edit template shows a compact grid of tiles — each a transparent square box containing an SVG rectangle proportioned to the ratio (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, 21:9) with the ratio label beneath. Stores the plain ratio token, ideal for feeding image-generation requests.",
13+
"cardInfo": {
14+
"name": null,
15+
"notes": null,
16+
"summary": null,
17+
"cardThumbnailURL": null
18+
}
19+
},
20+
"relationships": {
21+
"skills": {
22+
"links": {
23+
"self": null
24+
}
25+
},
26+
"tags.0": {
27+
"links": {
28+
"self": "../../../Tag/field"
29+
}
30+
},
31+
"license": {
32+
"links": {
33+
"self": null
34+
}
35+
},
36+
"specs.0": {
37+
"links": {
38+
"self": "../Spec/aspect-ratio-field-spec"
39+
}
40+
},
41+
"specs.1": {
42+
"links": {
43+
"self": "../../../Spec/code-snippet"
44+
}
45+
},
46+
"images.0": {
47+
"links": {
48+
"self": "../Screenshots/b5b018fc-1f70-476d-8db1-49e82afea93f-56b01670.png"
49+
}
50+
},
51+
"publisher": {
52+
"links": {
53+
"self": null
54+
}
55+
},
56+
"examples.0": {
57+
"links": {
58+
"self": "../example/AspectRatioExample/b5b018fc-1f70-476d-8db1-49e82afea93f"
59+
}
60+
},
61+
"categories.0": {
62+
"links": {
63+
"self": "../../../Category/ui-components-design"
64+
}
65+
},
66+
"cardInfo.theme": {
67+
"links": {
68+
"self": null
69+
}
70+
},
71+
"cardInfo.cardThumbnail": {
72+
"links": {
73+
"self": null
74+
}
75+
}
76+
}
77+
}
78+
}
41.2 KB
Loading
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"data": {
3+
"type": "card",
4+
"attributes": {
5+
"ref": {
6+
"name": "default",
7+
"module": "../aspect-ratio"
8+
},
9+
"specType": "field",
10+
"cardTitle": "Aspect Ratio Field",
11+
"cardDescription": "A string field for choosing an image aspect ratio (1:1, 16:9, 9:16, …). Its custom edit template renders a compact grid of tiles, each a transparent square box holding an SVG rectangle proportioned to the ratio with the ratio label beneath — a nicer alternative to a plain dropdown.",
12+
"readMe": "## Aspect Ratio Field\n\nA string field whose value is an aspect-ratio token such as `1:1` or `16:9`. In edit mode it renders a grid of selectable tiles — each shows an SVG rectangle drawn to the ratio's proportions inside a transparent square box, with the ratio label underneath.\n\n### Usage\n\n```ts\nimport { CardDef, field, contains } from 'https://cardstack.com/base/card-api';\nimport AspectRatioField from '@cardstack/catalog/fields/aspect-ratio/aspect-ratio';\n\nexport class Example extends CardDef {\n @field aspectRatio = contains(AspectRatioField);\n}\n```\n\nRender the picker in any template with `<@fields.aspectRatio @format='edit' />`. The stored value is the plain ratio string, so it is easy to pass to an image-generation request.",
13+
"cardInfo": {
14+
"name": null,
15+
"notes": null,
16+
"summary": null,
17+
"cardThumbnailURL": null
18+
},
19+
"containedExamples": []
20+
},
21+
"relationships": {
22+
"cardInfo.theme": {
23+
"links": {
24+
"self": null
25+
}
26+
},
27+
"linkedExamples": {
28+
"links": {
29+
"self": null
30+
}
31+
},
32+
"cardInfo.cardThumbnail": {
33+
"links": {
34+
"self": null
35+
}
36+
}
37+
},
38+
"meta": {
39+
"adoptsFrom": {
40+
"name": "Spec",
41+
"module": "https://cardstack.com/base/spec"
42+
}
43+
}
44+
}
45+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { Component } from 'https://cardstack.com/base/card-api';
2+
import StringField from 'https://cardstack.com/base/string';
3+
import { eq } from '@cardstack/boxel-ui/helpers';
4+
import { on } from '@ember/modifier';
5+
import { fn } from '@ember/helper';
6+
7+
// A framing option. Each carries a preview rectangle (proportioned to the
8+
// ratio, centered in a 22×22 box) rendered by the custom edit template.
9+
interface AspectOption {
10+
value: string;
11+
label: string;
12+
orientation: string;
13+
rw: number;
14+
rh: number;
15+
rx: number;
16+
ry: number;
17+
}
18+
19+
const AR_VIEWBOX = 22;
20+
const AR_MAX = 18; // largest shape dimension inside the box
21+
const round2 = (n: number) => Math.round(n * 100) / 100;
22+
23+
export const ASPECT_RATIO_OPTIONS: AspectOption[] = [
24+
{ value: '1:1', label: '1:1', orientation: 'Square', w: 1, h: 1 },
25+
{ value: '16:9', label: '16:9', orientation: 'Landscape', w: 16, h: 9 },
26+
{ value: '9:16', label: '9:16', orientation: 'Portrait', w: 9, h: 16 },
27+
{ value: '4:3', label: '4:3', orientation: 'Landscape', w: 4, h: 3 },
28+
{ value: '3:4', label: '3:4', orientation: 'Portrait', w: 3, h: 4 },
29+
{ value: '3:2', label: '3:2', orientation: 'Landscape', w: 3, h: 2 },
30+
{ value: '2:3', label: '2:3', orientation: 'Portrait', w: 2, h: 3 },
31+
{ value: '21:9', label: '21:9', orientation: 'Ultrawide', w: 21, h: 9 },
32+
].map(({ value, label, orientation, w, h }) => {
33+
let rw = w >= h ? AR_MAX : (AR_MAX * w) / h;
34+
let rh = h >= w ? AR_MAX : (AR_MAX * h) / w;
35+
return {
36+
value,
37+
label,
38+
orientation,
39+
rw: round2(rw),
40+
rh: round2(rh),
41+
rx: round2(AR_VIEWBOX / 2 - rw / 2),
42+
ry: round2(AR_VIEWBOX / 2 - rh / 2),
43+
};
44+
});
45+
46+
// A string field whose value is an aspect-ratio token (e.g. '16:9'). The custom
47+
// edit template renders a compact grid of tiles — each a transparent square box
48+
// holding an SVG rectangle proportioned to the ratio, with the ratio label
49+
// beneath — instead of a plain dropdown.
50+
export default class AspectRatioField extends StringField {
51+
static displayName = 'Aspect Ratio';
52+
53+
static edit = class Edit extends Component<typeof AspectRatioField> {
54+
options = ASPECT_RATIO_OPTIONS;
55+
56+
<template>
57+
<div class='ar-grid' data-test-aspect-ratio>
58+
{{#each this.options as |opt|}}
59+
<button
60+
type='button'
61+
class='ar-tile {{if (eq @model opt.value) "selected"}}'
62+
title='{{opt.orientation}} · {{opt.label}}'
63+
{{on 'click' (fn @set opt.value)}}
64+
data-test-aspect-ratio-option={{opt.value}}
65+
>
66+
<span class='ar-box'>
67+
<svg viewBox='0 0 22 22' class='ar-svg' aria-hidden='true'>
68+
<rect
69+
x={{opt.rx}}
70+
y={{opt.ry}}
71+
width={{opt.rw}}
72+
height={{opt.rh}}
73+
rx='1.5'
74+
/>
75+
</svg>
76+
</span>
77+
<span class='ar-label'>{{opt.label}}</span>
78+
</button>
79+
{{/each}}
80+
</div>
81+
82+
<style scoped>
83+
.ar-grid {
84+
display: grid;
85+
grid-template-columns: repeat(4, 1fr);
86+
gap: var(--boxel-sp-xxs);
87+
}
88+
.ar-tile {
89+
display: flex;
90+
flex-direction: column;
91+
align-items: center;
92+
gap: 0.125rem;
93+
padding: var(--boxel-sp-xxs);
94+
border: 1px solid var(--boxel-200);
95+
border-radius: var(--boxel-border-radius-sm);
96+
background: transparent;
97+
color: var(--boxel-450);
98+
font: inherit;
99+
cursor: pointer;
100+
transition:
101+
border-color 0.12s ease,
102+
color 0.12s ease;
103+
}
104+
.ar-tile:hover {
105+
border-color: var(--boxel-highlight);
106+
}
107+
.ar-tile.selected {
108+
border-color: var(--boxel-highlight);
109+
color: var(--boxel-dark);
110+
box-shadow: 0 0 0 0.0625rem var(--boxel-highlight);
111+
}
112+
.ar-box {
113+
width: 1.5rem;
114+
height: 1.5rem;
115+
display: flex;
116+
align-items: center;
117+
justify-content: center;
118+
}
119+
.ar-svg {
120+
width: 100%;
121+
height: 100%;
122+
}
123+
.ar-svg rect {
124+
fill: none;
125+
stroke: currentColor;
126+
stroke-width: 1.5;
127+
}
128+
.ar-tile.selected .ar-svg rect {
129+
fill: color-mix(in srgb, var(--boxel-highlight) 25%, transparent);
130+
}
131+
.ar-label {
132+
font-size: var(--boxel-font-size-xs);
133+
font-weight: 600;
134+
}
135+
</style>
136+
</template>
137+
};
138+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"data": {
3+
"meta": {
4+
"adoptsFrom": {
5+
"name": "AspectRatioExample",
6+
"module": "@cardstack/catalog/fields/aspect-ratio/example/aspect-ratio-playground-example"
7+
}
8+
},
9+
"type": "card",
10+
"attributes": {
11+
"title": "Aspect Ratio Example",
12+
"cardInfo": {
13+
"name": "Aspect Ratio Example",
14+
"notes": null,
15+
"summary": null,
16+
"cardThumbnailURL": null
17+
},
18+
"aspectRatio": "1:1",
19+
"description": "A string field for choosing an image aspect ratio. Its custom edit template renders a grid of tiles, each an SVG rectangle proportioned to the ratio with the label beneath — a nicer alternative to a plain dropdown."
20+
},
21+
"relationships": {
22+
"cardInfo.theme": {
23+
"links": {
24+
"self": null
25+
}
26+
},
27+
"cardInfo.cardThumbnail": {
28+
"links": {
29+
"self": null
30+
}
31+
}
32+
}
33+
}
34+
}

0 commit comments

Comments
 (0)