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
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>`.
bun.lockmodified+2-2
Changes to bun.lock
@@ -10,7 +10,7 @@
1010 },
1111 "devDependencies": {
1212 "@iconify/json": "^2.2.535",
13 "@stacksjs/bumpx": "^0.2.21",
13 "@stacksjs/bumpx": "0.2.25",
1414 "better-dx": "^0.2.38",
1515 },
1616 },
@@ -1755,7 +1755,7 @@
17551755
17561756 "@stacksjs/benchmarks": ["@stacksjs/benchmarks@workspace:packages/benchmarks"],
17571757
1758 "@stacksjs/bumpx": ["@stacksjs/bumpx@0.2.21", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/logsmith": "^0.2.6", "bunfig": "^0.15.6" }, "bin": { "bumpx": "./dist/bin/cli.js" } }, "sha512-OFbqVX35ksAMlRQ7cEUltEqmA0zoBQdK4Zv0Me5FBlFQDt1JIo7K+uE9zCLIry4dyNa7MoKxCeGdGhH4iJbtQA=="],
1758 "@stacksjs/bumpx": ["@stacksjs/bumpx@0.2.25", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/logsmith": "^0.2.6", "bunfig": "^0.15.6" }, "bin": { "bumpx": "./dist/bin/cli.js" } }, "sha512-gReU/g2BZ7XCTwSVf7Cf8ImnZFWEyXlh9eZDZNN6SOUf0VvNOCxKt+bfg9JG8QPro/M8FVy3XayTvVVOfUmFVw=="],
17591759
17601760 "@stacksjs/bunpress": ["@stacksjs/bunpress@0.2.14", "", { "dependencies": { "@cwcss/crosswind": "^0.2.16", "@stacksjs/clapp": "^0.2.0", "@stacksjs/stx": "^0.2.171", "@stacksjs/ts-i18n": "^0.1.11", "bunfig": "^0.15.17", "ts-syntax-highlighter": "^0.2.14" }, "peerDependencies": { "@stacksjs/ts-cloud": "^0.7.51" }, "optionalPeers": ["@stacksjs/ts-cloud"], "bin": { "bunpress": "./dist/bin/cli.js" } }, "sha512-o0Bf8b7kXKSyH5xXrVrdA46gPzMVssLBnbP82t8WzF17gIveFLNW+CzEZe3n9o+Ui2wiy2+6IF5SwGfrdTtWcQ=="],
17611761
package.jsonmodified+1-1
Changes to package.json
@@ -117,7 +117,7 @@
117117 },
118118 "devDependencies": {
119119 "@iconify/json": "^2.2.535",
120 "@stacksjs/bumpx": "^0.2.21",
120 "@stacksjs/bumpx": "0.2.25",
121121 "better-dx": "^0.2.38"
122122 },
123123 "overrides": {
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})