ReviewOS

also looking at this

stacks/stx

fix(image): apply builtin image fitting props

#2094
Merged chris wants to merge codex/hq-image-object-fit into main
5 files +49 -3

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

CLAUDE.mdmodified+1-0
Changes to CLAUDE.md
@@ -635,6 +635,7 @@ External links (`http://`, `https://`, `mailto:`, `tel:`) stay as `<a href>` —
635635- `placeholder="blur"` (default) — inline SVG blur placeholder while loading; `placeholder="color"` + `placeholderColor="#…"` for a solid fill
636636- `lazy={false}` — opt out of lazy loading for above-the-fold heroes
637637- `preload` — emits a `<link rel="preload">` hint
638- `objectFit="contain"` / `objectPosition="left center"` — fit and align the image inside its box; `fit` / `position` are aliases shared with `<Image>`
638639- `provider="cloudinary"` / `"imgix"` / `"bunny"` — CDN URL rewriting; `quality` and `densities="1x 2x 3x"` are also accepted
639640
640641For genuinely dynamic URLs with no transforms, `x-src` / `x-alt` on a plain `<img>` is acceptable. Otherwise prefer `<StxImage>`.
packages/stx/src/builtins/stx-image.tsmodified+5-0
Changes to packages/stx/src/builtins/stx-image.ts
@@ -240,6 +240,8 @@ export const StxImageBuiltin: BuiltinComponentDef = {
240240 const provider = resolveProp(props, 'provider')
241241 const className = resolveProp(props, 'class') || resolveProp(props, 'className') || ''
242242 const style = resolveProp(props, 'style') || ''
243 const objectFit = resolveProp(props, 'objectFit') || resolveProp(props, 'fit')
244 const objectPosition = resolveProp(props, 'objectPosition') || resolveProp(props, 'position')
243245 const quality = resolveProp(props, 'quality')
244246
245247 const lazy = resolveBoolProp(props, 'lazy', true)
@@ -277,6 +279,8 @@ export const StxImageBuiltin: BuiltinComponentDef = {
277279 // ── Aspect ratio for CLS prevention ──────────────────────────
278280 const styles: string[] = []
279281 if (style) styles.push(style)
282 if (objectFit) styles.push(`object-fit:${objectFit}`)
283 if (objectPosition) styles.push(`object-position:${objectPosition}`)
280284
281285 if (numWidth && numHeight) {
282286 styles.push(`aspect-ratio:${numWidth}/${numHeight}`)
@@ -365,6 +369,7 @@ export const StxImageBuiltin: BuiltinComponentDef = {
365369 const consumedStatic = new Set([
366370 'src', 'alt', 'width', 'height', 'lazy', 'sizes', 'srcset',
367371 'placeholder', 'placeholderColor', 'class', 'className', 'style',
372 'objectFit', 'objectPosition', 'fit', 'position',
368373 'format', 'provider', 'quality', 'densities', 'preload', 'priority', 'picture', 'loading', 'decoding',
369374 ])
370375
packages/stx/test/components/builtin-image-fit.test.tsadded+40-0
Changes to packages/stx/test/components/builtin-image-fit.test.ts
@@ -0,0 +1,40 @@
1import path from 'node:path'
2import { describe, expect, it } from 'bun:test'
3import { processDirectives } from '../../src/process'
4
5const pagePath = path.resolve(__dirname, 'image-fit-page.stx')
6
7function render(template: string, context: Record<string, unknown> = {}): Promise<string> {
8 return processDirectives(template, context, pagePath, { debug: false }, new Set<string>())
9}
10
11describe('builtin image fitting', () => {
12 it.each(['Image', 'StxImage'])('applies %s layout props to the image style', async (component) => {
13 const output = await render(`<${component} src="/wordmark.svg" alt="HQ" width="683" height="275" objectFit="contain" objectPosition="left center" priority />`)
14 expect(output).toContain('object-fit:contain')
15 expect(output).toContain('object-position:left center')
16 expect(output).toContain('aspect-ratio:683/275')
17 expect(output).toContain('loading="eager"')
18 expect(output).not.toMatch(/\sobject(?:Fit|Position)=/i)
19 })
20
21 it('resolves server values and keeps author styles and attributes', async () => {
22 const output = await render('<Image src="/wordmark.svg" alt="HQ" :objectFit="fitMode" :objectPosition="anchor" style="width:96px;height:36px" data-role="brand" />', { fitMode: 'contain', anchor: 'center' })
23 expect(output).toContain('width:96px;height:36px;object-fit:contain;object-position:center')
24 expect(output).toContain('data-role="brand"')
25 expect(output).not.toMatch(/\sobject(?:Fit|Position)=/i)
26 })
27
28 it('accepts the Image component aliases without leaking them to HTML', async () => {
29 const output = await render('<Image src="/wordmark.svg" alt="HQ" fit="contain" position="top" />')
30 expect(output).toContain('object-fit:contain')
31 expect(output).toContain('object-position:top')
32 expect(output).not.toMatch(/\s(?:fit|position)=/)
33 })
34
35 it('leaves the browser default unchanged when no fit is requested', async () => {
36 const output = await render('<StxImage src="/photo.jpg" alt="Training" />')
37 expect(output).not.toContain('object-fit:')
38 expect(output).not.toContain('object-position:')
39 })
40})