Skip to content

Commit af62bc1

Browse files
committed
feat: expand Alchemy forms and refresh tool UX
- Adds full frontend support for newer Alchemy forms (`describe`, `palette`, `vectorize`) plus additional post-processors, including updated types, labels, and developer references. - Result rendering now handles structured/text/vector outputs, adds SVG preview sanitization, and introduces one-click raw result copy behavior. - The Alchemy page layout was reworked into a focused workbench flow with grouped operations, contextual form metadata (including a mobile info dialog), improved result navigation/focus cues, and corresponding i18n/style/report/archive test updates.
1 parent 7f46ca7 commit af62bc1

11 files changed

Lines changed: 2496 additions & 347 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {
2+
ChangeDetectionStrategy,
3+
Component,
4+
inject,
5+
signal,
6+
} from '@angular/core';
7+
import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
8+
import { TranslocoPipe } from '@jsverse/transloco';
9+
import { IconComponent } from '../../icon/icon.component';
10+
import { AlchemyFormName } from '../../../types/generation';
11+
12+
export interface AlchemyFormInfoDialogData {
13+
formName: AlchemyFormName;
14+
}
15+
16+
/**
17+
* Modal dialog that displays detailed information about an alchemy form.
18+
* Used on mobile/narrow screens where inline explanations would be offscreen.
19+
*/
20+
@Component({
21+
selector: 'app-alchemy-form-info-dialog',
22+
imports: [TranslocoPipe, IconComponent],
23+
template: `
24+
<div class="alchemy-form-info-dialog">
25+
<div class="alchemy-form-info-dialog__header">
26+
<h2 class="alchemy-form-info-dialog__title">
27+
{{ 'alchemy.form_names.' + formName() | transloco }}
28+
</h2>
29+
<button
30+
type="button"
31+
class="alchemy-form-info-dialog__close"
32+
(click)="close()"
33+
aria-label="Close dialog"
34+
>
35+
<app-icon name="x" />
36+
</button>
37+
</div>
38+
39+
<div class="alchemy-form-info-dialog__content">
40+
<div class="alchemy-form-info-dialog__section">
41+
<h3 class="alchemy-form-info-dialog__label">
42+
{{ 'alchemy.form_info_dialog.description' | transloco }}
43+
</h3>
44+
<p class="alchemy-form-info-dialog__text">
45+
{{ 'alchemy.form_meta.' + formName() + '.desc' | transloco }}
46+
</p>
47+
</div>
48+
49+
@if (hasExample()) {
50+
<div class="alchemy-form-info-dialog__section">
51+
<h3 class="alchemy-form-info-dialog__label">
52+
{{ 'alchemy.form_info_dialog.example' | transloco }}
53+
</h3>
54+
<p class="alchemy-form-info-dialog__text alchemy-form-info-dialog__text--example">
55+
{{ 'alchemy.form_meta.' + formName() + '.example' | transloco }}
56+
</p>
57+
</div>
58+
}
59+
</div>
60+
</div>
61+
`,
62+
changeDetection: ChangeDetectionStrategy.OnPush,
63+
})
64+
export class AlchemyFormInfoDialogComponent {
65+
private readonly dialogRef = inject(DialogRef);
66+
private readonly data = inject<AlchemyFormInfoDialogData>(DIALOG_DATA);
67+
68+
public readonly formName = signal(this.data.formName);
69+
70+
/**
71+
* Upscaler forms have badge metadata rather than example text.
72+
*/
73+
private readonly formsWithoutExamples = new Set<AlchemyFormName>([
74+
'strip_background',
75+
]);
76+
77+
public hasExample(): boolean {
78+
return !this.formsWithoutExamples.has(this.formName());
79+
}
80+
81+
public close(): void {
82+
this.dialogRef.close();
83+
}
84+
}

src/app/components/alchemy/alchemy-result/alchemy-result.component.html

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,32 @@ <h4 class="alchemy-result__title">
5353
| transloco: { tags: totalTags(), facets: facetCount() }
5454
}}
5555
}
56+
@case ("describe") {
57+
<span class="alchemy-result__summary-text">{{
58+
describeSummary()
59+
}}</span>
60+
}
61+
@case ("palette") {
62+
<span class="alchemy-palette-preview">
63+
@for (color of paletteColors(); track color.hex) {
64+
<span
65+
class="alchemy-palette-preview__swatch"
66+
[style.background-color]="color.hex"
67+
></span>
68+
}
69+
<span class="alchemy-result__summary-text">{{
70+
paletteSummary()
71+
}}</span>
72+
</span>
73+
}
74+
@case ("vectorize") {
75+
<span class="alchemy-result__summary-text">SVG</span>
76+
}
77+
@case ("text") {
78+
<span class="alchemy-result__summary-text">{{
79+
inlineText()
80+
}}</span>
81+
}
5682
@case ("image") {
5783
<img
5884
class="alchemy-result__thumb"
@@ -72,6 +98,28 @@ <h4 class="alchemy-result__title">
7298
(triggered)="openRawJson()"
7399
/>
74100
}
101+
102+
@if (rawCopyText()) {
103+
<button
104+
type="button"
105+
class="btn-json-inspector alchemy-result__copy-trigger"
106+
[attr.aria-label]="
107+
(copiedRaw()
108+
? 'alchemy.result.raw_copied'
109+
: 'alchemy.result.raw_copy'
110+
) | transloco
111+
"
112+
[attr.title]="
113+
(copiedRaw()
114+
? 'alchemy.result.raw_copied'
115+
: 'alchemy.result.raw_copy'
116+
) | transloco
117+
"
118+
(click)="copyRaw($event)"
119+
>
120+
<app-icon [name]="copiedRaw() ? 'check' : 'copy'" />
121+
</button>
122+
}
75123
</header>
76124

77125
@if (isOpen()) {
@@ -161,6 +209,49 @@ <h5 class="alchemy-tags__legend">
161209
}
162210
</div>
163211
}
212+
@case ("describe") {
213+
<dl class="alchemy-describe">
214+
@for (field of describeFields(); track field.key) {
215+
<div class="alchemy-describe__item">
216+
<dt>{{ field.label }}</dt>
217+
<dd>{{ field.value }}</dd>
218+
</div>
219+
}
220+
</dl>
221+
}
222+
@case ("palette") {
223+
<div class="alchemy-palette">
224+
@for (color of paletteColors(); track color.hex) {
225+
<div class="alchemy-palette__row">
226+
<span
227+
class="alchemy-palette__swatch"
228+
[style.background-color]="color.hex"
229+
></span>
230+
<span class="alchemy-palette__hex">{{ color.hex }}</span>
231+
<span class="alchemy-palette__bar">
232+
<span
233+
class="alchemy-palette__bar-fill"
234+
[style.width]="color.percentage"
235+
[style.background-color]="color.hex"
236+
></span>
237+
</span>
238+
<span class="alchemy-palette__percent">{{
239+
color.percentage
240+
}}</span>
241+
</div>
242+
}
243+
</div>
244+
}
245+
@case ("vectorize") {
246+
<div
247+
class="alchemy-vector"
248+
[innerHTML]="vectorSvgHtml()"
249+
aria-label="SVG preview"
250+
></div>
251+
}
252+
@case ("text") {
253+
<pre class="alchemy-result__text">{{ inlineText() }}</pre>
254+
}
164255
@default {
165256
<p class="form-hint">{{ "alchemy.result.raw_only" | transloco }}</p>
166257
}

src/app/components/alchemy/alchemy-result/alchemy-result.component.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,64 @@ describe('AlchemyResultComponent', () => {
8383
expect(c.imageUrl()).toBe('https://r2.example.com/out.webp');
8484
});
8585

86+
it('detects newer inline data forms keyed by form name', () => {
87+
const c = withForm({
88+
form: 'describe',
89+
state: 'done',
90+
result: { describe: { format: 'WEBP', width: 1024, height: 801 } },
91+
});
92+
expect(c.kind()).toBe('describe');
93+
expect(c.returnsImage()).toBe(false);
94+
expect(c.describeSummary()).toBe('WEBP · 1024px x 801px');
95+
expect(c.rawCopyText()).toContain('"format": "WEBP"');
96+
});
97+
98+
it('stringifies structured inline data form values for display', () => {
99+
const c = withForm({
100+
form: 'palette',
101+
state: 'done',
102+
result: {
103+
palette: { colors: [{ hex: '#111111', proportion: 0.25 }] },
104+
},
105+
});
106+
expect(c.kind()).toBe('palette');
107+
expect(c.paletteColors()).toEqual([
108+
{ hex: '#111111', proportion: 0.25, percentage: '25.0%' },
109+
]);
110+
expect(c.rawCopyText()).toContain('#111111');
111+
});
112+
113+
it('renders vectorize SVG as sanitized inline SVG while keeping raw SVG copy text', () => {
114+
const svg =
115+
'<?xml version="1.0" encoding="UTF-8"?>\n<!-- Generator: visioncortex VTracer 0.6.12 -->\n<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="1024" height="801"><path d="M0 0h10v10H0z"/></svg>';
116+
const c = withForm({
117+
form: 'vectorize',
118+
state: 'done',
119+
result: { vectorize: svg },
120+
});
121+
expect(c.kind()).toBe('vectorize');
122+
expect(c.vectorSvgMarkup()).toContain('<svg version="1.1"');
123+
expect(c.vectorSvgPreviewMarkup()).toContain('<svg');
124+
expect(c.vectorSvgPreviewMarkup()).toContain('viewBox="0 0 1024 801"');
125+
expect(c.vectorSvgHtml()).toBeTruthy();
126+
expect(c.rawCopyText()).toBe(svg);
127+
});
128+
129+
it('strips active content from vectorize SVG previews', () => {
130+
const c = withForm({
131+
form: 'vectorize',
132+
state: 'done',
133+
result: {
134+
vectorize:
135+
'<svg onload="alert(1)"><script>alert(1)</script><path onclick="alert(2)" d="M0 0h1v1"/></svg>',
136+
},
137+
});
138+
expect(c.vectorSvgPreviewMarkup()).toContain('<svg');
139+
expect(c.vectorSvgPreviewMarkup()).not.toContain('script');
140+
expect(c.vectorSvgPreviewMarkup()).not.toContain('onload');
141+
expect(c.vectorSvgPreviewMarkup()).not.toContain('onclick');
142+
});
143+
86144
it('falls back to unknown for an unrecognized shape without throwing', () => {
87145
const c = withForm({
88146
form: 'caption',

0 commit comments

Comments
 (0)