From 8dcb787f619ef19316a77adb59850a53db0f672d Mon Sep 17 00:00:00 2001 From: Lucas Law Date: Thu, 23 Jul 2026 12:20:03 +0800 Subject: [PATCH 1/4] Redesign Image Source editor: hero preview + link-or-URL empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit UI drops the header + File/URL tabs + large dashed dropzone in favor of one surface with two moves: - Empty: a two-panel invitation — "Add an image" (accent icon tile + the standard linksTo Link Image button) | or | "Add image URL" (compact input with an Add suffix). Stacks to one column in narrow hosts via container query. - With an image: a hero preview (object-fit: contain with minor padding) and a bordered ✕ remove that returns to the empty state. - Picking a file switches sourceMode to file (deferred to a microtask — render-time mutations are dropped); adding a URL sets sourceMode to url. - All mutating handlers no-op when the contained field value is undefined (an unset contains() renders the editor with no model). Styling now resolves through three scopes: the --img-source-* family knob (shared with the Multi Image Source editor, so one override re-skins both) → semantic theme tokens → boxel/literal fallbacks, with resolved values in --img-* aliases. Accent deliberately skips the app-global --primary, and --ring/--boxel-highlight are re-pointed locally so the input focus ring matches the accent. Tests rewritten from tab assertions to empty-state / hero / add-by-url / remove behaviors. Data model is unchanged (url, file, sourceMode, resolvedUrl). linear: https://linear.app/cardstack/issue/ECO-526/image-source-field-editor-ui-enhancement-hero-link-or-url-redesign --- .../components/image-source-editor.gts | 706 ++++++++---------- fields/image-source/image-source.test.gts | 78 +- 2 files changed, 361 insertions(+), 423 deletions(-) diff --git a/fields/image-source/components/image-source-editor.gts b/fields/image-source/components/image-source-editor.gts index 810c8b8f..2b1ca06a 100644 --- a/fields/image-source/components/image-source-editor.gts +++ b/fields/image-source/components/image-source-editor.gts @@ -1,21 +1,11 @@ import Component from '@glimmer/component'; -import SparkleIcon from '@cardstack/boxel-icons/sparkle'; -import type { BoxComponent } from 'https://cardstack.com/base/card-api'; -import { fn } from '@ember/helper'; +import { tracked } from '@glimmer/tracking'; import { on } from '@ember/modifier'; -import { eq } from '@cardstack/boxel-ui/helpers'; -import { - BoxelInputGroup, - IconButton, - Pill, -} from '@cardstack/boxel-ui/components'; -import { - File, - IconMinusCircle, - IconLink, - ImagePlaceholder, - Upload, -} from '@cardstack/boxel-ui/icons'; +import { modifier } from 'ember-modifier'; +import PhotoPlusIcon from '@cardstack/boxel-icons/photo-plus'; +import type { BoxComponent } from 'https://cardstack.com/base/card-api'; +import { BoxelInputGroup } from '@cardstack/boxel-ui/components'; +import { IconLink, IconX } from '@cardstack/boxel-ui/icons'; import type { ImageSourceMode } from '../image-source'; import { selectedSourceMode } from '../utils'; @@ -33,7 +23,14 @@ interface ImageSourceEditorSignature { }; } +// One surface, two moves — the same design language as the Multi Image +// Source editor. With an image: a hero preview with a remove button. Empty: +// a two-panel invitation — link a workspace image, or add by URL. Styling +// resolves --img-source-* knob → semantic theme token → boxel/literal (three-scope), +// so themed hosts restyle both editors at once. export default class ImageSourceEditor extends Component { + @tracked urlDraft = ''; + get sourceMode(): ImageSourceMode { return selectedSourceMode( this.args.model?.sourceMode, @@ -41,439 +38,376 @@ export default class ImageSourceEditor extends Component { - this.args.model.sourceMode = mode; - }; - - onUrlChange = (val: string) => { - this.args.model.url = val; - }; - - onFileRemove = () => { - this.args.model.file = undefined; - }; - - get hasFile() { - return Boolean(this.filePreviewSrc); + get resolvedUrl(): string { + return this.sourceMode === 'url' + ? (this.args.model?.url ?? '') + : (this.args.model?.file?.url ?? ''); } - get hasUrl() { - return Boolean(this.args.model?.url); + get hasImage() { + return Boolean(this.resolvedUrl); } - get imageUrl() { - return this.args.model?.url ?? ''; - } + // BoxelInputGroup's @onInput passes the value itself, not an event + onUrlInput = (value: string) => { + this.urlDraft = value; + }; - get filePreviewSrc() { - return this.args.model?.file?.url ?? ''; - } + addUrl = (event: Event) => { + event.preventDefault(); + let model = this.args.model; + let url = this.urlDraft.trim(); + if (!model || !url) return; + model.url = url; + model.sourceMode = 'url'; + this.urlDraft = ''; + }; - get defaultSourceLabel() { - return this.sourceMode === 'url' ? 'Default: URL' : 'Default: File Upload'; - } + removeImage = () => { + let model = this.args.model; + if (!model) return; + model.file = undefined; + model.url = null; + }; + + // picking a file through the linksTo editor makes file the active source + // (deferred to a microtask — mutating during render is dropped) + adoptPickedFile = modifier((_element: HTMLElement, [file]: [any]) => { + if (file?.url && this.sourceMode !== 'file') { + void Promise.resolve().then(() => { + this.args.model.sourceMode = 'file'; + }); + } + }); diff --git a/fields/image-source/image-source.test.gts b/fields/image-source/image-source.test.gts index 2c35f719..003876b2 100644 --- a/fields/image-source/image-source.test.gts +++ b/fields/image-source/image-source.test.gts @@ -1,3 +1,4 @@ +import { click, fillIn } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupBaseRealm } from '@cardstack/host/tests/helpers/base-realm'; @@ -66,7 +67,7 @@ export function runTests() { .exists('editor component renders'); }); - test('image-source field edit defaults to file tab when no sourceMode or url', async function (assert) { + test('image-source field edit shows the empty state when no url or file', async function (assert) { await renderField( ImageSourceField, buildField(ImageSourceField, {}), @@ -74,17 +75,17 @@ export function runTests() { ); assert - .dom('[data-test-image-source-file-tab]') - .hasAttribute('aria-selected', 'true', 'file tab active by default'); + .dom('[data-test-image-source-preview]') + .doesNotExist('no hero preview without an image'); assert - .dom('[data-test-image-source-url-tab]') - .hasAttribute('aria-selected', 'false', 'url tab inactive'); + .dom('[data-test-image-source-file-field]') + .exists('link-an-image panel shown'); assert - .dom('[data-test-image-source-file-panel]') - .exists('file panel shown'); + .dom('[data-test-image-source-url-input]') + .exists('url input present'); }); - test('image-source field edit shows url tab active when sourceMode is url', async function (assert) { + test('image-source field edit shows the hero preview when url is set', async function (assert) { await renderField( ImageSourceField, buildField(ImageSourceField, { @@ -95,56 +96,59 @@ export function runTests() { ); assert - .dom('[data-test-image-source-url-tab]') - .hasAttribute('aria-selected', 'true', 'url tab active'); + .dom('[data-test-image-source-preview] img') + .hasAttribute( + 'src', + 'https://example.com/photo.jpg', + 'hero shows the resolved image', + ); assert - .dom('[data-test-image-source-url-panel]') - .exists('url panel shown'); + .dom('[data-test-image-source-remove]') + .exists('remove button present on the hero'); }); - test('image-source field edit url panel shows preview when url is set', async function (assert) { + test('image-source field edit adds an image by url', async function (assert) { + // sourceMode alone yields a real (still image-less) field instance — + // buildField({}) returns undefined, which renders but cannot be edited await renderField( ImageSourceField, - buildField(ImageSourceField, { - url: 'https://example.com/photo.jpg', - sourceMode: 'url', - }), + buildField(ImageSourceField, { sourceMode: 'file' }), 'edit', ); - assert - .dom('[data-test-image-source-url-preview]') - .exists('url preview renders when url is set'); - }); - - test('image-source field edit url panel shows empty state when no url', async function (assert) { - await renderField( - ImageSourceField, - buildField(ImageSourceField, { sourceMode: 'url' }), - 'edit', + await fillIn( + '[data-test-image-source-editor] input', + 'https://example.com/added.jpg', ); + await click('[data-test-image-source-url-add]'); assert - .dom('[data-test-image-source-url-preview]') - .doesNotExist('no preview when url is empty'); - assert - .dom('[data-test-image-source-url-input]') - .exists('url input present'); + .dom('[data-test-image-source-preview] img') + .hasAttribute( + 'src', + 'https://example.com/added.jpg', + 'added url becomes the hero image', + ); }); - test('image-source field edit shows file picker when sourceMode is file and no file linked', async function (assert) { + test('image-source field edit remove returns to the empty state', async function (assert) { await renderField( ImageSourceField, - buildField(ImageSourceField, { sourceMode: 'file' }), + buildField(ImageSourceField, { + url: 'https://example.com/photo.jpg', + sourceMode: 'url', + }), 'edit', ); + await click('[data-test-image-source-remove]'); + assert - .dom('[data-test-image-source-file-panel]') - .exists('file panel renders'); + .dom('[data-test-image-source-preview]') + .doesNotExist('hero cleared after remove'); assert .dom('[data-test-image-source-file-field]') - .exists('file picker shown when no file linked'); + .exists('back to the empty state'); }); }); } From 4d5966a89a1bb5dffa7300956f0c1713ff401e1b Mon Sep 17 00:00:00 2001 From: Lucas Law Date: Thu, 23 Jul 2026 14:27:28 +0800 Subject: [PATCH 2/4] Address review: theme-derived hero surface, destroy guard, sturdier test selector - The hero letterbox surface derives from the themed bg/text pair via color-mix instead of raw --boxel-100, so dark-themed hosts stay dark. - adoptPickedFile's deferred mutation bails once the component is destroyed, and its comment now states the write-on-open self-heal for instances persisted as { file: set, sourceMode: 'url' }. - The add-by-url test fills the input inside its own labeled group instead of "the only input in the editor". --- fields/image-source/components/image-source-editor.gts | 10 ++++++++-- fields/image-source/image-source.test.gts | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/fields/image-source/components/image-source-editor.gts b/fields/image-source/components/image-source-editor.gts index 2b1ca06a..7cf9301a 100644 --- a/fields/image-source/components/image-source-editor.gts +++ b/fields/image-source/components/image-source-editor.gts @@ -1,5 +1,6 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; +import { isDestroyed, isDestroying } from '@ember/destroyable'; import { on } from '@ember/modifier'; import { modifier } from 'ember-modifier'; import PhotoPlusIcon from '@cardstack/boxel-icons/photo-plus'; @@ -71,10 +72,13 @@ export default class ImageSourceEditor extends Component { if (file?.url && this.sourceMode !== 'file') { void Promise.resolve().then(() => { + if (isDestroyed(this) || isDestroying(this)) return; this.args.model.sourceMode = 'file'; }); } @@ -207,7 +211,9 @@ export default class ImageSourceEditor extends Component Date: Thu, 23 Jul 2026 14:33:41 +0800 Subject: [PATCH 3/4] fix instance example --- .../c3f5e2e5-0065-4a41-8f1f-3bf339a3f4c4.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/fields/image-source/example/ImageSourceExample/c3f5e2e5-0065-4a41-8f1f-3bf339a3f4c4.json b/fields/image-source/example/ImageSourceExample/c3f5e2e5-0065-4a41-8f1f-3bf339a3f4c4.json index 2a395785..ad01553e 100644 --- a/fields/image-source/example/ImageSourceExample/c3f5e2e5-0065-4a41-8f1f-3bf339a3f4c4.json +++ b/fields/image-source/example/ImageSourceExample/c3f5e2e5-0065-4a41-8f1f-3bf339a3f4c4.json @@ -9,7 +9,7 @@ "type": "card", "attributes": { "image": { - "url": "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=800", + "url": "https://images.pexels.com/photos/36862786/pexels-photo-36862786.jpeg", "sourceMode": "url" }, "title": "Image Source Example", @@ -31,6 +31,11 @@ "links": { "self": null } + }, + "image.file": { + "links": { + "self": null + } } } } From d7c1245b2cb685ec78655502c5337f32368a7f88 Mon Sep 17 00:00:00 2001 From: Lucas Law Date: Thu, 23 Jul 2026 14:39:43 +0800 Subject: [PATCH 4/4] fix missing path on remix --- fields/image-source/example/image-source-playground-example.gts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fields/image-source/example/image-source-playground-example.gts b/fields/image-source/example/image-source-playground-example.gts index 6d29463d..59d95d35 100644 --- a/fields/image-source/example/image-source-playground-example.gts +++ b/fields/image-source/example/image-source-playground-example.gts @@ -5,7 +5,7 @@ import { field, } from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; -import CodeSnippet from '../../../components/code-snippet'; +import CodeSnippet from '@cardstack/catalog/components/code-snippet'; import ImageSourceField from '../image-source'; const usageCode = `@field image = contains(ImageSourceField);`;