From f237acb985aff7a814b567d7a5c8f88902ffeb9c Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:32:06 -0700 Subject: [PATCH 01/18] fix(admin): keep the lookup's source post URL through a repeat lookup, and pin the merged SONA-156 and SONA-220 rows (SONA-220) On the upload form a second artist lookup blanked the source post URL the first one had written before the new result was in, and the tag suggestion control answered that change by dropping its chips and rating. The fields now wait for the result: a lookup that lands on the same post leaves the URL untouched, a different post replaces it, and no post clears it. The panel's own state (applied artist, extra parents) still resets when the search starts. A parent change keeps the full reset. The lookup's rating pill wraps at every width on both forms, and shares the wrapped indent with the suggestion note in the NSFW row. Tests pin the joined aria-describedby on the source field, the three re-seed lines in the edit page's resetForImage, the parent-move reset, the clash sentence after a lookup-written URL, and both directions of the "set that lookup aside" line. The e2e watch-ignores test now knows the two SONA-220 persist roots. --- src/app.css | 5 +- src/lib/artist-lookup-markup.test.ts | 41 +- src/lib/e2e-watch-ignores.test.ts | 8 +- .../admin/images/[id]/edit/+page.svelte | 21 +- src/routes/admin/upload/+page.svelte | 78 ++-- tests/e2e/artist-lookup.spec.ts | 359 ++++++++++++++++++ tests/e2e/suggest-tags.spec.ts | 36 +- 7 files changed, 494 insertions(+), 54 deletions(-) diff --git a/src/app.css b/src/app.css index 10531b60..4206608a 100644 --- a/src/app.css +++ b/src/app.css @@ -554,8 +554,11 @@ select.input { @media (max-width: 640px) { /* Wrapped onto its own line, the note lines up under the label text rather - than under the checkbox. */ + than under the checkbox. The lookup's rating pill (SONA-156) shares the + row and wraps the same way, so it takes the same indent — otherwise the + two pills wrap to two different left edges. */ .tag-check-row .tag-rating-note, + .tag-check-row .rating-tag, .tag-check-row .btn { margin-left: 24px; } diff --git a/src/lib/artist-lookup-markup.test.ts b/src/lib/artist-lookup-markup.test.ts index 5905fe8c..271f8402 100644 --- a/src/lib/artist-lookup-markup.test.ts +++ b/src/lib/artist-lookup-markup.test.ts @@ -14,6 +14,9 @@ const EDIT = read('src/routes/admin/images/[id]/edit/+page.svelte'); const PANEL = read('src/lib/components/ArtistLookupPanel.svelte'); const DIALOG = read('src/lib/components/NewArtistDialog.svelte'); const ANNOUNCER = read('src/lib/components/LiveAnnouncer.svelte'); +// The row the two rating pills share is styled globally, beside the +// tag-suggestion note it also holds (SONA-220). +const APP_CSS = read('src/app.css'); describe('lookup button and its disclosure hint', () => { it('offers the button only when a key is configured, on both pages', () => { @@ -678,7 +681,10 @@ describe('round 11 wiring', () => { for (const source of [UPLOAD, EDIT]) { expect(source).toMatch(/sourceUrlHeld=\{(shared|lookup)UrlHeld\}/); expect(source).not.toContain("sourceUrlHeld={sourcePostUrl.trim() !== ''}"); - expect(source).toMatch(/(shared|lookup)UrlHeld = sourcePostUrl\.trim\(\) !== '';/); + // The upload page reads the value the operator OWNS rather than the raw + // field: a URL its own last prefill wrote is not held by anybody, and + // that one is no longer blanked before the result lands. + expect(source).toMatch(/(shared|lookup)UrlHeld = (ownSource|sourcePostUrl)\.trim\(\) !== '';/); } }); @@ -1045,9 +1051,13 @@ describe('focus after the panel goes away', () => { // Same on the upload page, where a second lookup replaces the shared // prefill: the first clash's piece stops being on offer unless the operator // chose it, and dropping a chosen one would blank the select instead. + // In resetSharedResult, which resetSharedPrefill calls and a starting + // lookup calls on its own: the clash is about the search that is over, + // while the two shared FIELDS now wait for the new result to land. expect(UPLOAD).toMatch( - /function resetSharedPrefill\(\)[\s\S]{0,900}?extraParents = extraParents\.filter\(\(c\) => String\(c\.id\) === existingParentId\);/ + /function resetSharedResult\(\)[\s\S]{0,900}?extraParents = extraParents\.filter\(\(c\) => String\(c\.id\) === existingParentId\);/ ); + expect(UPLOAD).toMatch(/function resetSharedPrefill\(\)[\s\S]{0,400}?resetSharedResult\(\);/); // And on the edit page's own repeat lookup, which resetForImage does not // cover: it only runs on a move to another image. expect(EDIT).toMatch( @@ -1082,7 +1092,7 @@ describe('focus after the panel goes away', () => { // "new" radio re-deriving the shared fields from the parent tile. it('applies the shared prefill by the role the tile has when the result lands', () => { expect(UPLOAD).toMatch( - /function startLookup\(key: number\)[\s\S]{0,900}?if \(isParent\(key\)\) resetSharedPrefill\(\);/ + /function startLookup\(key: number\)[\s\S]{0,1400}?if \(isParent\(key\)\) resetSharedResult\(\);/ ); expect(UPLOAD).toMatch(/if \(isParent\(key\)\) applyShared\(next\);/); expect(UPLOAD).not.toMatch(/wasParent/); @@ -1114,7 +1124,7 @@ describe('focus after the panel goes away', () => { // still-selected artist also spared it on a SECOND lookup, whose result // names somebody else, leaving "Using {name}" over an unrelated match. expect(UPLOAD).toMatch( - /function resetSharedPrefill\(\)[\s\S]{0,900}?\n\t\tappliedArtist = null;/ + /function resetSharedResult\(\)[\s\S]{0,900}?\n\t\tappliedArtist = null;/ ); expect(UPLOAD).not.toMatch(/if \(!appliedArtist \|\| Number\(selectedArtistId\) !== appliedArtist\.id\)/); expect(UPLOAD).toMatch( @@ -1191,25 +1201,30 @@ describe('the rating tag beside NSFW', () => { } }); - it('keeps the tag on one line and lets the row wrap instead', () => { + it('lets the row wrap a pill to its own line', () => { for (const source of [UPLOAD, EDIT]) { - expect(source).toMatch(/\.rating-tag \{[^}]*white-space: nowrap;/); expect(source).toMatch(/\.nsfw-row[\s\S]{0,80}\{[^}]*flex-wrap: wrap;/); } + // Wrapped, the two pills and the "Mark it NSFW" button all line up under + // the label text rather than one of them under the checkbox. + expect(APP_CSS).toMatch( + /@media \(max-width: 640px\) \{[\s\S]{0,400}?\.tag-check-row \.tag-rating-note,\s+\.tag-check-row \.rating-tag,\s+\.tag-check-row \.btn \{\s+margin-left: 24px;/ + ); }); - // The text grows with the number of sites; nowrap alone made the pill wider - // than its column and gave the whole document a horizontal scrollbar at - // 320px (1.4.10), and wider than its tile in the grid. + // The text grows with the number of sites; nowrap made the pill wider than + // its column and gave the whole document a horizontal scrollbar at 320px + // (1.4.10), wider than its tile in the grid, and — kept on one line beside a + // second pill and a button — it pushed that button onto a row of its own. it('lets the pill wrap rather than pushing the page sideways', () => { for (const source of [UPLOAD, EDIT]) { expect(source).toMatch(/\.rating-tag \{[^}]*max-width: 100%/); - expect(source).toMatch( - /@media \(max-width: 480px\) \{[\s\S]{0,200}?\.rating-tag \{[^}]*white-space: normal/ - ); + expect(source).toMatch(/\.rating-tag \{[^}]*white-space: normal;/); + expect(source).toMatch(/\.rating-tag \{[^}]*overflow-wrap: anywhere;/); + // No rule anywhere puts it back on one line. + expect(source).not.toMatch(/\.rating-tag \{[^}]*white-space: nowrap;/); } expect(UPLOAD).toMatch(/\.tile-nsfw-row \{[^}]*min-width: 0/); - expect(UPLOAD).toMatch(/\.tile-nsfw-row \.rating-tag \{[^}]*white-space: normal/); }); }); diff --git a/src/lib/e2e-watch-ignores.test.ts b/src/lib/e2e-watch-ignores.test.ts index fcfbe27b..91c7b3e5 100644 --- a/src/lib/e2e-watch-ignores.test.ts +++ b/src/lib/e2e-watch-ignores.test.ts @@ -5,7 +5,9 @@ import { E2E_PERSIST_TO, E2E_PERSIST_TO_RECOVERY, E2E_PERSIST_TO_UT, - E2E_PERSIST_TO_UPLOAD + E2E_PERSIST_TO_UPLOAD, + E2E_PERSIST_TO_TAGS, + E2E_PERSIST_TO_SUGGEST } from '../../tests/e2e/paths'; // Read the resolved config rather than the source text: the glob only silences @@ -46,7 +48,9 @@ describe('the dev server does not watch the e2e harness it is running under', () E2E_PERSIST_TO, E2E_PERSIST_TO_RECOVERY, E2E_PERSIST_TO_UT, - E2E_PERSIST_TO_UPLOAD + E2E_PERSIST_TO_UPLOAD, + E2E_PERSIST_TO_TAGS, + E2E_PERSIST_TO_SUGGEST ]; expect(new Set(roots).size).toBe(roots.length); for (const root of roots) { diff --git a/src/routes/admin/images/[id]/edit/+page.svelte b/src/routes/admin/images/[id]/edit/+page.svelte index c990d7b7..9d14cce5 100644 --- a/src/routes/admin/images/[id]/edit/+page.svelte +++ b/src/routes/admin/images/[id]/edit/+page.svelte @@ -1052,8 +1052,8 @@ } /* The rating never changes the checkbox — it reports what the sites said and - sits beside it. nowrap so the sentence stays one unit, and the row wraps - the whole pill to its own line when it no longer fits. */ + sits beside it. The row wraps a pill to its own line when it no longer + fits. */ .nsfw-row { display: flex; align-items: center; @@ -1061,6 +1061,11 @@ flex-wrap: wrap; } + /* The text grows with the number of sites, so the pill wraps rather than + pushing the document into a sideways scroll. Kept on one line it cannot + shrink at all, which on this narrower form pushed the suggestion's own + note and its button onto a second row while the upload form kept them + inline at the same width. */ .rating-tag { font-family: var(--font-primary); font-size: 11px; @@ -1068,19 +1073,11 @@ border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 1px 8px; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; max-width: 100%; } - /* The text grows with the number of sites, so at narrow widths the pill - wraps rather than pushing the document into a sideways scroll. */ - @media (max-width: 480px) { - .rating-tag { - white-space: normal; - overflow-wrap: anywhere; - } - } - .artist-toggle { display: flex; gap: 4px; diff --git a/src/routes/admin/upload/+page.svelte b/src/routes/admin/upload/+page.svelte index 95e58cec..ccf79ea8 100644 --- a/src/routes/admin/upload/+page.svelte +++ b/src/routes/admin/upload/+page.svelte @@ -570,7 +570,15 @@ lookupAborts.set(key, controller); tile.lookup = { kind: 'searching' }; tile.sentPrivate = isPrivate; - if (isParent(key)) resetSharedPrefill(); + // The fields the last prefill wrote are NOT emptied here. Blanking the + // source post URL for the length of the round trip tears down everything + // downstream of it — the suggestion control drops its standing chips and + // its rating the moment that field changes (SONA-220) — and a lookup that + // comes back with the same post, or fails outright, then has nothing to + // put back. They are replaced where the result lands instead, and only + // when the result names a different post. What the panel says about the + // LAST result does go now: it is about a search that is over. + if (isParent(key)) resetSharedResult(); // What runLookup settled on, so the catch below can keep this lookup's own // answer to "did the file leave the browser" instead of assuming it did. let settled: LookupState | null = null; @@ -796,6 +804,14 @@ dateTagged = false; sharedFilled = {}; sharedUrlHeld = false; + resetSharedResult(); + } + + /** What the last result put on the page OUTSIDE the two shared fields. A new + * search invalidates all of it the moment it starts, because none of it is + * recoverable from the result that is on its way. The fields are the + * exception and are handled by applyShared when that result lands. */ + function resetSharedResult() { // A second lookup's panel is about a new result, and an artist applied // from the last one is not applied to it: cleared here, the way the edit // page's resetLookupPrefill does. The group-mode round trip, which shows @@ -813,19 +829,45 @@ * about the refill has to know that before it claims one happened. */ function applyShared(next: LookupState): { sourcePostUrl: boolean; commissionedAt: boolean } { const wrote = { sourcePostUrl: false, commissionedAt: false }; + // A failure, or a search cancelled back to idle, leaves both fields exactly + // as they are: there is no new post to describe them, and what the last + // lookup wrote is still the best thing the page knows. if (next.kind !== 'results') return wrote; - sharedUrlHeld = sourcePostUrl.trim() !== ''; - const fields = prefillForResult(next.data, { sourcePostUrl, commissionedAt }); + // A field the LAST prefill wrote and the operator has not typed over since + // is still the lookup's to replace, so this result reads it as empty and + // fills it. Only the tag can tell the two apart, which is why the value is + // kept until here rather than blanked when the search started. + const ownSource = sourceTagged ? '' : sourcePostUrl; + const ownDate = dateTagged ? '' : commissionedAt; + sharedUrlHeld = ownSource.trim() !== ''; + const fields = prefillForResult(next.data, { + sourcePostUrl: ownSource, + commissionedAt: ownDate + }); sharedFilled = fields; if (fields.sourcePostUrl !== undefined) { - sourcePostUrl = fields.sourcePostUrl; + // Written only when it is really different. Re-assigning the same URL + // still counts as a change to everything watching the field, and the + // suggestion control answers one by dropping the chips and the rating it + // is holding — for a second lookup that landed on the same post. + if (sourcePostUrl !== fields.sourcePostUrl) sourcePostUrl = fields.sourcePostUrl; sourceTagged = true; wrote.sourcePostUrl = true; + } else if (sourceTagged) { + // This result has no post to offer — no match, or a clash whose URL + // belongs to another piece — so the last one's URL goes now. Deferred to + // here rather than done at the start: until the result was in, there was + // no way to know it would not be refilled. + sourcePostUrl = ''; + sourceTagged = false; } if (fields.commissionedAt !== undefined) { - commissionedAt = fields.commissionedAt; + if (commissionedAt !== fields.commissionedAt) commissionedAt = fields.commissionedAt; dateTagged = true; wrote.commissionedAt = true; + } else if (dateTagged) { + commissionedAt = ''; + dateTagged = false; } return wrote; } @@ -2046,8 +2088,8 @@ } /* The rating never changes the checkbox — it reports what the sites said and - sits beside it. nowrap so the sentence stays one unit, and the row wraps - the whole pill to its own line when it no longer fits. */ + sits beside it. The row wraps a pill to its own line when it no longer + fits. */ .nsfw-row, .tile-nsfw-row { display: flex; @@ -2062,6 +2104,11 @@ min-width: 0; } + /* The text grows with the number of sites, so the pill wraps inside whatever + holds it rather than spilling out: the tile is ~170px wide, and the shared + row is beside a second pill and a button once a suggestion has run. Kept + on one line the pill cannot shrink at all, which pushed the document into + a sideways scroll on a narrow phone. */ .rating-tag { font-family: var(--font-primary); font-size: 11px; @@ -2069,24 +2116,9 @@ border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 1px 8px; - white-space: nowrap; - max-width: 100%; - } - - /* The tile is ~170px wide and the text grows with the number of sites, so - the pill wraps inside the tile rather than spilling out of it. */ - .tile-nsfw-row .rating-tag { white-space: normal; overflow-wrap: anywhere; - } - - /* Same story for the shared row once the column itself is narrow: one line - of pill is worth less than a page that doesn't scroll sideways. */ - @media (max-width: 480px) { - .rating-tag { - white-space: normal; - overflow-wrap: anywhere; - } + max-width: 100%; } .field-label { diff --git a/tests/e2e/artist-lookup.spec.ts b/tests/e2e/artist-lookup.spec.ts index 444c1953..e94407f2 100644 --- a/tests/e2e/artist-lookup.spec.ts +++ b/tests/e2e/artist-lookup.spec.ts @@ -1,6 +1,7 @@ import { test, expect, type Page } from '@playwright/test'; import { adminLogin } from './admin-login'; import { dropOn, waitForDropAttachment } from './drop-files'; +import { stubSuggestions } from './tag-suggestions-helpers'; // "Look up artist" on the upload page (SONA-156), driven in a real browser. // Nothing renders Svelte under vitest, so the unit suite can only grep the @@ -47,6 +48,54 @@ function matchedBody(over: Record = {}) { }; } +// An X status the tag-suggestion pill also recognises. The two features only +// meet on a post BOTH of them can read: FuzzySearch matches it, and the pill +// runs on Bluesky and X links alone — so a FurAffinity match leaves the +// suggestion control refusing and only one pill on the page. +const X_POST = 'https://x.com/kuttoya/status/1789012345678901234'; + +/** One confident match on that X post, rated adult, so the lookup fills the + * source field with a URL the suggestion pill will accept. */ +function xMatchBody() { + return matchedBody({ + matches: [ + { + site: 'Twitter', + siteId: '1789012345678901234', + handles: ['kuttoya'], + distance: 0, + band: 'exact', + postedAt: '2026-03-04T10:00:00Z', + rating: 'adult', + postUrl: X_POST + } + ] + }); +} + +// A second X status, for the case where a repeat lookup lands somewhere else. +const X_POST_2 = 'https://x.com/kuttoya/status/1789012345678909999'; + +/** The X match with a source clash on it: the post already belongs to another + * piece, so the prefill deliberately leaves the source URL alone. */ +function xClashBody(imageId: number, title: string) { + return { + ...xMatchBody(), + sourceClash: { + imageId, + title, + isVariant: false, + parentImageId: null, + variantCount: 0, + thumbnailUrl: null, + artistName: 'Test Artist', + uploadedAt: '2026-07-09T00:00:00.000Z', + width: 1200, + height: 900 + } + }; +} + /** The same confident match, plus a source clash on a piece the page never * loaded an option for. */ function clashBody(imageId: number, title: string) { @@ -202,6 +251,8 @@ async function saveLookupKey(page: Page) { } const pill = (page: Page) => page.locator('button.lookup-pill'); +// The tag-suggestion control's own pill, beside the Tags field (SONA-220). +const suggestPill = (page: Page) => page.getByRole('button', { name: 'Suggest tags', exact: true }); // The page's own polite region (the admin layout has a separate one, a

). const LIVE_REGION = 'div.sr-only[aria-live="polite"]'; const panel = (page: Page) => page.getByRole('region', { name: 'Artist lookup' }); @@ -490,6 +541,314 @@ test.describe('with a key saved', () => { await expect(nsfw).not.toBeChecked(); }); + // ---- Both rating pills at once (SONA-156 + SONA-220) -------------------- + // The merge put two classifiers beside ONE checkbox, and nothing here or in + // tag-suggestions.spec.ts had ever drawn them together: that spec's server + // holds no FuzzySearch key, so the lookup pill is not even on its page. + // These run here, where the key is saved, with the suggestion endpoint + // stubbed the same way it is over there. + + test('both rating pills sit beside the one NSFW box, and neither ticks it', async ({ page }) => { + await stubLookup(page, xMatchBody()); + await stubSuggestions(page, 200, { + source: 'x', + tags: ['mammal', 'canine', 'fox'], + rating: 'explicit', + imageCount: 1 + }); + await oneDoneTile(page); + const nsfw = page.locator('input[name="nsfw"]'); + + // The lookup fills the source field with the post it matched, which is what + // lets the suggestion run on the same piece without anything being typed. + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(X_POST); + await expect(page.locator('#shared-rating-tag')).toHaveText('Rated Adult on Twitter'); + + await expect(suggestPill(page)).toHaveAttribute('aria-disabled', 'false'); + await suggestPill(page).click(); + await expect(page.locator('#tags-rating')).toHaveText('Rated explicit by entail.dev.'); + + // Both on screen, and the box points at BOTH of them: pinned to one id, the + // describedby silently drops whichever classifier ran second. + await expect(page.locator('#shared-rating-tag')).toBeVisible(); + const described = (await nsfw.getAttribute('aria-describedby'))?.split(' ') ?? []; + expect(described).toContain('shared-rating-tag'); + expect(described).toContain('tags-rating'); + await expect(nsfw).toHaveAccessibleDescription(/Rated Adult on Twitter/); + await expect(nsfw).toHaveAccessibleDescription(/Rated explicit by entail.dev/); + // Two guesses about the artwork, still no decision about the gallery. + await expect(nsfw).not.toBeChecked(); + + // On a phone the row wraps, and everything that wraps lines up under the + // label text rather than under the checkbox. Read as the indent rather than + // as two boxes: the pill can now shrink its own text, so which line it + // lands on depends on how long the site list is, while the indent that + // decides where it lands when it wraps is the same either way. The + // suggestion's note and button already had it; the lookup's pill did not. + await page.setViewportSize({ width: 390, height: 900 }); + await expect(page.locator('#shared-rating-tag')).toHaveCSS('margin-left', '24px'); + await expect(page.locator('#tags-rating')).toHaveCSS('margin-left', '24px'); + await expect(page.getByRole('button', { name: 'Mark it NSFW' })).toHaveCSS( + 'margin-left', + '24px' + ); + // Whatever did wrap shares one left edge with the rest of the wrapped row. + const rowTop = (await page.locator('.tag-check-row .checkbox-label').boundingBox())?.y ?? 0; + const wrapped: number[] = []; + for (const id of ['#shared-rating-tag', '#tags-rating']) { + const box = await page.locator(id).boundingBox(); + if (!box) throw new Error(`${id} has no box`); + if (box.y > rowTop) wrapped.push(box.x); + } + expect(wrapped.length).toBeGreaterThan(0); + for (const x of wrapped) expect(Math.abs(x - wrapped[0])).toBeLessThanOrEqual(1); + + // And on the narrowest phone the long pill wraps its own text rather than + // pushing the document sideways. + await page.setViewportSize({ width: 320, height: 900 }); + await expect(page.locator('#shared-rating-tag')).toBeVisible(); + const overflow = await page.evaluate(() => { + const el = document.scrollingElement; + return el ? el.scrollWidth - el.clientWidth : 0; + }); + expect(overflow).toBeLessThanOrEqual(0); + }); + + test('the edit page draws both pills on one row too', async ({ page }) => { + await stubLookup(page, xMatchBody()); + await stubSuggestions(page, 200, { + source: 'x', + tags: ['mammal', 'canine', 'fox'], + rating: 'explicit', + imageCount: 1 + }); + // 1280 first: what the operator is most likely on, and the width the two + // forms disagreed at. + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoEditHydrated(page); + const nsfw = page.locator('input[name="nsfw"]'); + + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(X_POST); + await expect(page.locator('#lookup-rating-tag')).toHaveText('Rated Adult on Twitter'); + + await expect(suggestPill(page)).toHaveAttribute('aria-disabled', 'false'); + await suggestPill(page).click(); + await expect(page.locator('#tags-rating')).toHaveText('Rated explicit by entail.dev.'); + + const described = (await nsfw.getAttribute('aria-describedby'))?.split(' ') ?? []; + expect(described).toContain('lookup-rating-tag'); + expect(described).toContain('tags-rating'); + await expect(nsfw).toHaveAccessibleDescription(/Rated Adult on Twitter/); + await expect(nsfw).toHaveAccessibleDescription(/Rated explicit by entail.dev/); + await expect(nsfw).not.toBeChecked(); + + // Both pills fit on the row itself at this width. "Mark it NSFW" does not: + // the two forms hold the same row in different columns (this one is 600px + // wide, the upload form 800px), and the four items want about 606px here, + // so the button wraps on this page and not on that one. Left as an open + // question rather than pinned either way — closing it means changing how + // wide this whole form is, which is a layout decision about every field on + // the page and not something a rating pill gets to settle. + await expect(page.locator('#lookup-rating-tag')).toBeVisible(); + await expect(page.locator('#tags-rating')).toBeVisible(); + }); + + // ---- The Source Post URL field's own two descriptions -------------------- + // That field can be described twice at once as well: the suggestion control's + // hint refusing the link it holds (SONA-220) and the "From lookup" tag on the + // value a lookup wrote into it (SONA-156). Neither spec had ever put both + // there, so the join could have dropped one and stayed green. + + /** Lookup first so the source URL is filled AND tagged, then a refused + * suggestion on that same URL so the hint points at the field too. The other + * order pins nothing: a refusal leaves the operator's own URL in the field, + * which the lookup then declines to overwrite, so no tag is ever added. */ + async function bothSourceDescriptions(page: Page) { + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(X_POST); + await expect(page.locator('#source-lookup-tag')).toHaveText('From lookup'); + + await expect(suggestPill(page)).toHaveAttribute('aria-disabled', 'false'); + await suggestPill(page).click(); + await expect(page.locator('#tags-hint')).toHaveText( + "Sona can't look up this link. Check the source post URL." + ); + + // Both ids, space-separated, in one attribute — and both still resolve to + // something on the page, which is what an id in describedby is worth. + await expect(sourceInput(page)).toHaveAttribute( + 'aria-describedby', + 'tags-hint source-lookup-tag' + ); + await expect(page.locator('#tags-hint')).toBeVisible(); + await expect(page.locator('#source-lookup-tag')).toBeVisible(); + // Read as one description rather than as two ids, the way a screen reader + // would announce it on focus. + await expect(sourceInput(page)).toHaveAccessibleDescription( + /Sona can't look up this link[\s\S]*From lookup/ + ); + } + + test('the source URL field carries the refusal and the lookup tag at once', async ({ page }) => { + await stubLookup(page, xMatchBody()); + await stubSuggestions(page, 422, { error: 'unsupported_source' }); + await oneDoneTile(page); + await bothSourceDescriptions(page); + }); + + test('the edit page joins the same two descriptions on that field', async ({ page }) => { + await stubLookup(page, xMatchBody()); + await stubSuggestions(page, 422, { error: 'unsupported_source' }); + await gotoEditHydrated(page); + await bothSourceDescriptions(page); + }); + + test('a second lookup that lands on the same post keeps the suggested chips', async ({ + page + }) => { + // The source URL used to be blanked the moment the second lookup started + // and refilled only when it came back, so the suggestion control saw the + // post change and threw away chips the operator was still choosing from — + // for a lookup that landed on the very same post. + await stubLookup(page, xMatchBody()); + await stubSuggestions(page, 200, { + source: 'x', + tags: ['mammal', 'canine', 'fox'], + rating: 'explicit', + imageCount: 1 + }); + await oneDoneTile(page); + + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(X_POST); + await suggestPill(page).click(); + await expect(page.locator('.tag-chip')).toHaveCount(3); + + await pill(page).click(); + await expect(panel(page)).toContainText('kuttoya'); + await expect(sourceInput(page)).toHaveValue(X_POST); + await expect(page.locator('.tag-chip')).toHaveCount(3); + await expect(page.locator('#tags-rating')).toHaveText('Rated explicit by entail.dev.'); + + // And a lookup that fails outright leaves the field as it was, rather than + // emptying it with nothing to put back. + await stubLookup(page, { enabled: true, error: 'rate_limited', forwarded: true }, 429); + await pill(page).click(); + await expect(panel(page)).toContainText( + 'FuzzySearch is limiting how often your site can search right now.' + ); + await expect(sourceInput(page)).toHaveValue(X_POST); + await expect(page.locator('#source-lookup-tag')).toHaveText('From lookup'); + await expect(page.locator('.tag-chip')).toHaveCount(3); + + // Nothing was ever set aside, so nothing said it was. The control announces + // that only when the post under it really changes, and neither a repeat of + // the same post nor a failed lookup is that. + await expect(page.locator('#tags-status')).not.toContainText('set that lookup aside'); + }); + + test('a second lookup that lands elsewhere replaces the URL, and the chips go with it', async ({ + page + }) => { + // The other half of the same rule: keeping the field through the round trip + // must not turn into keeping a URL the new result disagrees with. The + // replacement happens when the result lands, and the suggestion about the + // old post goes then — not at the click, and not never. + await stubLookup(page, xMatchBody()); + await stubSuggestions(page, 200, { + source: 'x', + tags: ['mammal', 'canine', 'fox'], + rating: 'explicit', + imageCount: 1 + }); + await oneDoneTile(page); + + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(X_POST); + await suggestPill(page).click(); + await expect(page.locator('.tag-chip')).toHaveCount(3); + + await stubLookup( + page, + matchedBody({ + matches: [ + { + site: 'Twitter', + siteId: '1789012345678909999', + handles: ['kuttoya'], + distance: 0, + band: 'exact', + postedAt: '2026-03-05T10:00:00Z', + rating: 'adult', + postUrl: X_POST_2 + } + ] + }) + ); + await pill(page).click(); + + await expect(sourceInput(page)).toHaveValue(X_POST_2); + await expect(page.locator('#source-lookup-tag')).toHaveText('From lookup'); + await expect(page.locator('.tag-chip')).toHaveCount(0); + await expect(page.locator('#tags-rating')).toHaveCount(0); + await expect(page.locator('#tags-status')).toHaveText( + 'The source post URL changed, so Sona set that lookup aside.' + ); + }); + + // The click-time reset was narrowed so a repeat lookup keeps the field it is + // about to refill. Moving the PARENT is a different question with the same + // shape, and it keeps the full reset: the shared fields describe whichever + // tile is parent now, so a URL filled from the old parent's post must not + // survive under the new one still wearing "From lookup". + test('moving the parent still clears what the last lookup filled', async ({ page }) => { + await stubLookup(page, xMatchBody()); + await twoDoneTiles(page); + + // In a set the lookup is per tile, and the first tile is the parent. + await tileLookup(page).first().click(); + await expect(sourceInput(page)).toHaveValue(X_POST); + await expect(page.locator('#source-lookup-tag')).toHaveText('From lookup'); + await expect(dateInput(page)).toHaveValue('2026-03-04'); + + // The second tile has no result of its own, so there is nothing to + // re-derive and the fields go back to empty rather than keeping the first + // tile's post. + await page.getByRole('radio', { name: 'Parent: back.png' }).check(); + await expect(sourceInput(page)).toHaveValue(''); + await expect(dateInput(page)).toHaveValue(''); + await expect(page.locator('#source-lookup-tag')).toHaveCount(0); + await expect(page.locator('#commissioned-lookup-tag')).toHaveCount(0); + }); + + test('a clash after a lookup calls the field empty, not the operator\'s', async ({ page }) => { + // The clash sentence turns on whether the URL in the field was the + // OPERATOR'S when the prefill ran. The field now still holds the previous + // lookup's value at that moment, and that value is nobody's to keep: read + // as held, the panel would say Sona left "your" URL alone while the field + // it is talking about had just been emptied. + await stubLookup(page, xMatchBody()); + await oneDoneTile(page); + + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(X_POST); + await expect(page.locator('#source-lookup-tag')).toHaveText('From lookup'); + + await stubLookup(page, xClashBody(9001, 'Clash Piece')); + await pill(page).click(); + + await expect(panel(page)).toContainText( + 'left the source post URL empty, because that post is already the source of Clash Piece' + ); + await expect(panel(page)).not.toContainText('left your source post URL as it was'); + // And the sentence matches the field: the tagged value the first lookup + // wrote is gone, because this result had nothing to put in its place. + await expect(sourceInput(page)).toHaveValue(''); + await expect(page.locator('#source-lookup-tag')).toHaveCount(0); + }); + test('a refused key says so and offers Settings, not a retry', async ({ page }) => { await stubLookup(page, { enabled: true, error: 'key_refused', forwarded: true }, 424); await oneDoneTile(page); diff --git a/tests/e2e/suggest-tags.spec.ts b/tests/e2e/suggest-tags.spec.ts index 9c61931a..aba0f6b3 100644 --- a/tests/e2e/suggest-tags.spec.ts +++ b/tests/e2e/suggest-tags.spec.ts @@ -1125,6 +1125,10 @@ test('the edit page re-seeds its fields when a client-side navigation swaps the const storedUrl = await page.locator('input[name="sourcePostUrl"]').inputValue(); const storedParent = await page.locator('select[name="parentImageId"]').inputValue(); const storedArtist = await page.locator('select[name="artistId"]').inputValue(); + // The NSFW box is seeded from `data` the same way, and a suggestion's rating + // is what talks the operator into ticking it — read what 102 really stores so + // a tick made on 101 cannot pass as this image's own. + const storedNsfw = await page.locator('input[name="nsfw"]').isChecked(); await gotoRetrying(page, '/admin/images/101/edit'); const tags = page.locator('input[name="tags"]'); @@ -1183,11 +1187,30 @@ test('the edit page re-seeds its fields when a client-side navigation swaps the await expect(page.locator('input[name="artistName"]')).toBeVisible(); await expect(artist).toHaveCount(0); - // A tray open on image 101: its chips would otherwise still be there to Add - // onto image 102 after the navigation below. + // A suggestion accepted on image 101: the tags it wrote into the field, the + // rating it returned, and the NSFW tick that rating asked for are all three + // staged on THIS image. Carried across, image 102 would be saved with another + // image's tags under another image's rating, with its NSFW box moved by it. await stubSuggestions(page, 200, { source: 'bluesky', - tags: ['fox'], + tags: ['e2e-canine', 'e2e-vulpine'], + rating: 'explicit', + imageCount: 1 + }); + await pill.click(); + await expect(page.locator('.tag-tray .tag-chip')).toHaveCount(2); + await page.getByRole('button', { name: 'Add 2 tags' }).click(); + await expect(tags).toHaveValue('typed-on-101, e2e-canine, e2e-vulpine'); + const nsfwBox = page.locator('input[name="nsfw"]'); + await page.getByRole('button', { name: 'Mark it NSFW' }).click(); + await expect(nsfwBox).toBeChecked(); + await expect(page.locator('#tags-rating')).toHaveText('Rated explicit by entail.dev.'); + + // And a second tray, left open: its chips would otherwise still be there to + // Add onto image 102 after the navigation below. + await stubSuggestions(page, 200, { + source: 'bluesky', + tags: ['e2e-marine'], rating: 'safe', imageCount: 1 }); @@ -1212,6 +1235,12 @@ test('the edit page re-seeds its fields when a client-side navigation swaps the await expect(url).toHaveValue(storedUrl); await expect(parent).toHaveValue(storedParent); await expect(page.locator('.tag-tray')).toHaveCount(0); + // The accepted suggestion goes with the image it was about: no rating note + // beside the checkbox, and the checkbox back on what 102 stores rather than + // on the tick 101's explicit rating earned. + await expect(page.locator('#tags-rating')).toHaveCount(0); + await expect(page.locator('.tag-status-line')).toHaveCount(0); + expect(await nsfwBox.isChecked()).toBe(storedNsfw); // Image 102's own stored values, not image 101's edits. // The artist select is back, rather than the new-artist form left open on 101. @@ -1238,6 +1267,7 @@ test('the edit page re-seeds its fields when a client-side navigation swaps the await expect(page.locator('input[name="char-1"]')).not.toBeChecked(); await expect(page.locator('select[name="parentImageId"]')).toHaveValue(storedParent); await expect(page.locator('input[name="tags"]')).toHaveValue(storedTags); + expect(await page.locator('input[name="nsfw"]').isChecked()).toBe(storedNsfw); }); test('a failed lookup replaces the row pill with a tray that offers Try again', async ({ page }) => { From 22cad82ad4f795b8cede53a7854572d20e7a4e64 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:50:52 -0700 Subject: [PATCH 02/18] fix(admin): defer the edit page's lookup prefill too, announce emptied fields, and key the NSFW row to its column (SONA-220) The edit page now keeps the lookup-written source post URL and commissioned date until the new result lands, the same way the upload form does, so a repeat lookup no longer drops the tag-suggestion chips. When a result empties a field the previous lookup filled, the panel's status line says so instead of claiming the field was left alone. The NSFW row on both forms is laid out by the width of the form's column, not the window: both forms are a container, the row stacks with the 24px indent below 560px of column, and above it the two rating pills take a zero flex basis with a 14ch floor so the button stays beside the checkbox while the pills wrap their text. The pill radius is a fixed 12px so a wrapped pill keeps its end caps clear of the text. Tests cover the edit page's same-post, different-post and no-post repeat lookups, the emptied-field status kinds, the date-clearing branch on the upload form, and the row at 320, 900 and 1280. --- messages/en.json | 5 + messages/ja.json | 5 + src/app.css | 14 +- src/lib/artist-lookup-markup.test.ts | 145 ++++++++- src/lib/artist-lookup.test.ts | 69 ++++ src/lib/artist-lookup.ts | 48 ++- src/lib/components/ArtistLookupPanel.svelte | 31 +- .../admin/images/[id]/edit/+page.svelte | 102 +++++- src/routes/admin/upload/+page.svelte | 59 +++- tests/e2e/artist-lookup.spec.ts | 301 ++++++++++++++---- 10 files changed, 660 insertions(+), 119 deletions(-) diff --git a/messages/en.json b/messages/en.json index 9acdf946..01cdb172 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1983,6 +1983,11 @@ "admin_lookup_status_url_kept": "Sona filled the source post URL from the {site} post. You can change it before you save.", "admin_lookup_status_date_kept": "Sona filled the commissioned date from the {site} post. You can change it before you save.", "admin_lookup_status_kept": "Sona filled the commissioned date from the {site} post and left your source post URL as it was.", + "admin_lookup_status_url_and_date_emptied": "Sona filled the source post URL from the {site} post and emptied the commissioned date the last lookup filled, because that post carries no date. You can change the URL before you save.", + "admin_lookup_status_date_and_url_emptied": "Sona filled the commissioned date from the {site} post and emptied the source post URL the last lookup filled, because that post has no link to store. You can change the date before you save.", + "admin_lookup_status_both_emptied": "Sona emptied the source post URL and commissioned date the last lookup filled, because this result has nothing to put there.", + "admin_lookup_status_url_emptied": "Sona emptied the source post URL the last lookup filled, because this result has no post to put there.", + "admin_lookup_status_date_emptied": "Sona emptied the commissioned date the last lookup filled, because this result carries no date.", "admin_lookup_status_artist_hint": "Sets the artist to {name}.", "admin_lookup_status_seed_both": "Sona filled the new artist's name and {site} link. They're a guess until you check them, and you're the one publishing them.", "admin_lookup_status_seed_name": "Sona filled the new artist's name. It's a guess until you check it, and you're the one publishing it.", diff --git a/messages/ja.json b/messages/ja.json index cffc0511..4de80fe5 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -1582,6 +1582,11 @@ "admin_lookup_status_url_kept": "{site}の投稿から投稿元URLを入力しました。保存前に変更できます。", "admin_lookup_status_date_kept": "{site}の投稿から制作依頼日を入力しました。保存前に変更できます。", "admin_lookup_status_kept": "{site}の投稿から制作依頼日を入力しました。入力済みの投稿元URLはそのままにしています。", + "admin_lookup_status_url_and_date_emptied": "{site}の投稿から投稿元URLを入力しました。その投稿には日付がないため、前回のルックアップが入力した制作依頼日は空にしました。保存前にURLを変更できます。", + "admin_lookup_status_date_and_url_emptied": "{site}の投稿から制作依頼日を入力しました。その投稿には保存できるリンクがないため、前回のルックアップが入力した投稿元URLは空にしました。保存前に日付を変更できます。", + "admin_lookup_status_both_emptied": "今回の結果には入れられるものがないため、前回のルックアップが入力した投稿元URLと制作依頼日を空にしました。", + "admin_lookup_status_url_emptied": "今回の結果には入れられる投稿がないため、前回のルックアップが入力した投稿元URLを空にしました。", + "admin_lookup_status_date_emptied": "今回の結果には日付がないため、前回のルックアップが入力した制作依頼日を空にしました。", "admin_lookup_status_artist_hint": "アーティストに{name}を設定します。", "admin_lookup_status_seed_both": "新しいアーティストの名前と{site}のリンクを入力しました。確認するまでは推測にすぎず、公開するのはあなたです。", "admin_lookup_status_seed_name": "新しいアーティストの名前を入力しました。確認するまでは推測にすぎず、公開するのはあなたです。", diff --git a/src/app.css b/src/app.css index 4206608a..3049da71 100644 --- a/src/app.css +++ b/src/app.css @@ -552,11 +552,21 @@ select.input { flex-wrap: wrap; } -@media (max-width: 640px) { +/* Where this row breaks is decided by the COLUMN it sits in, not by the window. + Both admin forms hold it inside a grid column that is far narrower than the + viewport for most of the range — the edit form's column is 219px wide in an + 800px window — so a viewport query indented the phone and left the whole + middle of the range wrapping to the checkbox's edge. Both forms name + themselves `admin-form` instead, and this asks the column. */ +@container admin-form (max-width: 559px) { /* Wrapped onto its own line, the note lines up under the label text rather than under the checkbox. The lookup's rating pill (SONA-156) shares the row and wraps the same way, so it takes the same indent — otherwise the - two pills wrap to two different left edges. */ + two pills wrap to two different left edges. CSS cannot ask whether an item + wrapped, so the indent is by column width alone: a short pill that still + fits inline just under 560px carries a 24px gap. That is what the note + beside it has always done, and matching it beats a third behaviour on one + row. */ .tag-check-row .tag-rating-note, .tag-check-row .rating-tag, .tag-check-row .btn { diff --git a/src/lib/artist-lookup-markup.test.ts b/src/lib/artist-lookup-markup.test.ts index 271f8402..a6f72dae 100644 --- a/src/lib/artist-lookup-markup.test.ts +++ b/src/lib/artist-lookup-markup.test.ts @@ -537,7 +537,7 @@ describe('the "From lookup" tag', () => { // cannot drop a field it did not write. It still cannot carry a PREVIOUS // result's seed: applyPrefill empties it before the new one is described. expect(EDIT).toMatch( - /function applyPrefill\([\s\S]{0,300}?lookupSeeded = \{\};/ + /function applyPrefill\([\s\S]{0,600}?lookupSeeded = \{\};/ ); expect(EDIT).toMatch( /const lookupSeedEdited = \$derived\(\{[\s\S]{0,300}?artistName: lookupSeeded\.artistName !== undefined && !nameTagged/ @@ -562,7 +562,9 @@ describe('the "From lookup" tag', () => { expect(PANEL).toContain("statusKind === 'date_kept'"); expect(PANEL).toContain('m.admin_lookup_status_url_kept('); expect(PANEL).toContain('m.admin_lookup_status_date_kept('); - expect(PANEL).toMatch(/statusLineKind\(filled, \{ clash: !!clash, edited, urlHeld: sourceUrlHeld \}\)/); + expect(PANEL).toMatch( + /statusLineKind\(filled, \{ clash: !!clash, edited, urlHeld: sourceUrlHeld, cleared \}\)/ + ); expect(PANEL).toMatch(/seedStatusKind\(seeded, seedEdited\)/); }); @@ -630,10 +632,10 @@ describe('the artist on the edit page', () => { it('undoes the previous lookup before running another one', () => { expect(EDIT).toMatch(/function startLookup\(\)[\s\S]{0,400}?resetLookupPrefill\(\);/); const reset = EDIT.match(/function resetLookupPrefill\(\)[\s\S]*?\n\t\}/)?.[0] ?? ''; - // Only what the lookup itself wrote: the tag is the record of that. + // Only what the lookup itself wrote: the tag is the record of that. The + // inline new-artist fields go at the click; the two prefilled fields wait + // for the result, which is the only thing that can refill them (SONA-220). for (const [tag, field] of [ - ['sourceTagged', 'sourcePostUrl'], - ['dateTagged', 'commissionedAt'], ['nameTagged', 'artistName'], ['twitterTagged', 'newTwitter'], ['furaffinityTagged', 'newFuraffinity'] @@ -641,8 +643,19 @@ describe('the artist on the edit page', () => { expect(reset).toMatch(new RegExp(`if \\(${tag}\\) ${field} = '';`)); expect(reset).toMatch(new RegExp(`${tag} = false;`)); } + expect(reset).not.toMatch(/sourceTagged|dateTagged/); expect(reset).toMatch(/lookupFilled = \{\};/); expect(reset).toMatch(/lookupSeeded = \{\};/); + // The two the reset no longer owns are still undone, by the result. + const applyPrefill = EDIT.match(/function applyPrefill\([\s\S]*?\n\t\}/)?.[0] ?? ''; + for (const [tag, field] of [ + ['sourceTagged', 'sourcePostUrl'], + ['dateTagged', 'commissionedAt'] + ]) { + expect(applyPrefill).toMatch(new RegExp(`\\} else if \\(${tag}\\) \\{`)); + expect(applyPrefill).toMatch(new RegExp(`${field} = '';`)); + expect(applyPrefill).toMatch(new RegExp(`${tag} = false;`)); + } }); // Five of the inline form's inputs (Bluesky, Telegram, DeviantArt, Patreon, @@ -681,10 +694,10 @@ describe('round 11 wiring', () => { for (const source of [UPLOAD, EDIT]) { expect(source).toMatch(/sourceUrlHeld=\{(shared|lookup)UrlHeld\}/); expect(source).not.toContain("sourceUrlHeld={sourcePostUrl.trim() !== ''}"); - // The upload page reads the value the operator OWNS rather than the raw - // field: a URL its own last prefill wrote is not held by anybody, and - // that one is no longer blanked before the result lands. - expect(source).toMatch(/(shared|lookup)UrlHeld = (ownSource|sourcePostUrl)\.trim\(\) !== '';/); + // Both pages read the value the operator OWNS rather than the raw field: + // a URL their own last prefill wrote is not held by anybody, and that one + // is no longer blanked before the result lands. + expect(source).toMatch(/(shared|lookup)UrlHeld = ownSource\.trim\(\) !== '';/); } }); @@ -692,7 +705,10 @@ describe('round 11 wiring', () => { // the sentence goes away as soon as the operator edits the field the lookup // filled — which says nothing about whether the artist is still unapplied. it('renders the artist hint on its own condition, not the status line\'s', () => { - const status = PANEL.match(/\{#if statusKind !== 'none' && prefill\}[\s\S]*?\{\/if\}/)?.[0] ?? ''; + const status = + PANEL.match(/\{#if statusKind !== 'none' && \(prefill \|\| emptiedOnly\)\}[\s\S]*?\{\/if\}/)?.[0] ?? + ''; + expect(status).toContain('admin_lookup_status_both'); expect(status).not.toContain('admin_lookup_status_artist_hint'); expect(PANEL).toMatch( /const artistHintShown = \$derived\(\s*\n?\s*editMode && outcome === 'existing' && !appliedArtist && !!candidates\[0\]/ @@ -1132,6 +1148,67 @@ describe('focus after the panel goes away', () => { ); }); + // A repeat lookup used to blank the source post URL at the click and refill it + // when the result came back. Everything downstream of that field saw it change + // twice — the tag-suggestion control drops its chips and its rating on any + // change (SONA-220) — for a lookup that landed on the very same post. Both + // pages now keep the two prefilled fields until the result is in. + it('keeps the two prefilled fields until the result lands, on both pages', () => { + // Neither click-time reset touches them any more. + expect(UPLOAD).not.toMatch( + /function resetSharedResult\(\)[\s\S]{0,900}?if \(sourceTagged\) sourcePostUrl = '';/ + ); + expect(EDIT).not.toMatch( + /function resetLookupPrefill\(\)[\s\S]{0,900}?if \(sourceTagged\) sourcePostUrl = '';/ + ); + expect(EDIT).not.toMatch( + /function resetLookupPrefill\(\)[\s\S]{0,900}?if \(dateTagged\) commissionedAt = '';/ + ); + // The inline new-artist fields still go at the click: nothing downstream + // watches them, and the announcement in startLookup says they went. + expect(EDIT).toMatch( + /function resetLookupPrefill\(\)[\s\S]{0,400}?if \(nameTagged\) artistName = '';/ + ); + expect(EDIT).toMatch(/resetLookupPrefill\(\);/); + for (const [source, apply] of [ + [UPLOAD, 'applyShared'], + [EDIT, 'applyPrefill'] + ] as const) { + // The result reads a still-tagged value as the lookup's own, so it may + // refill it. Only the tag tells that from something the operator typed. + expect(source).toMatch( + new RegExp( + `function ${apply}\\([\\s\\S]{0,900}?const ownSource = sourceTagged \\? '' : sourcePostUrl;\\s+const ownDate = dateTagged \\? '' : commissionedAt;` + ) + ); + // And where the result offers nothing, the deferred field is emptied + // there instead — recorded, so the status line can say so (4.1.3). + expect(source).toMatch( + new RegExp( + `function ${apply}\\([\\s\\S]{0,2000}?\\} else if \\(sourceTagged\\) \\{[\\s\\S]{0,600}?sourcePostUrl = '';\\s+sourceTagged = false;\\s+cleared\\.sourcePostUrl = true;` + ) + ); + expect(source).toMatch( + new RegExp( + `function ${apply}\\([\\s\\S]{0,2400}?\\} else if \\(dateTagged\\) \\{\\s+commissionedAt = '';\\s+dateTagged = false;\\s+cleared\\.commissionedAt = true;` + ) + ); + expect(source).toMatch(/cleared=\{(shared|lookup)Cleared\}/); + } + // The panel reads that record, and says the field was emptied rather than + // left as it was. + expect(PANEL).toMatch(/statusLineKind\(filled, \{ clash: !!clash, edited, urlHeld: sourceUrlHeld, cleared \}\)/); + for (const key of [ + 'admin_lookup_status_url_and_date_emptied', + 'admin_lookup_status_date_and_url_emptied', + 'admin_lookup_status_both_emptied', + 'admin_lookup_status_url_emptied', + 'admin_lookup_status_date_emptied' + ]) { + expect(PANEL).toContain(`m.${key}`); + } + }); + // The Remove button lives inside the tile it removes, so activating it from // the keyboard dropped focus on and the next Tab restarted at the top // of the page (2.4.3). Every tile's button also read "Remove file" (2.4.6). @@ -1206,10 +1283,14 @@ describe('the rating tag beside NSFW', () => { expect(source).toMatch(/\.nsfw-row[\s\S]{0,80}\{[^}]*flex-wrap: wrap;/); } // Wrapped, the two pills and the "Mark it NSFW" button all line up under - // the label text rather than one of them under the checkbox. + // the label text rather than one of them under the checkbox. Asked of the + // COLUMN: both forms hold this row in a grid column far narrower than the + // window, so a viewport query indented the phone and left every width in + // between wrapping to the checkbox's edge. expect(APP_CSS).toMatch( - /@media \(max-width: 640px\) \{[\s\S]{0,400}?\.tag-check-row \.tag-rating-note,\s+\.tag-check-row \.rating-tag,\s+\.tag-check-row \.btn \{\s+margin-left: 24px;/ + /@container admin-form \(max-width: 559px\) \{[\s\S]{0,900}?\.tag-check-row \.tag-rating-note,\s+\.tag-check-row \.rating-tag,\s+\.tag-check-row \.btn \{\s+margin-left: 24px;/ ); + expect(APP_CSS).not.toMatch(/@media \(max-width: 640px\)[\s\S]{0,900}?\.tag-check-row/); }); // The text grows with the number of sites; nowrap made the pill wider than @@ -1220,12 +1301,50 @@ describe('the rating tag beside NSFW', () => { for (const source of [UPLOAD, EDIT]) { expect(source).toMatch(/\.rating-tag \{[^}]*max-width: 100%/); expect(source).toMatch(/\.rating-tag \{[^}]*white-space: normal;/); - expect(source).toMatch(/\.rating-tag \{[^}]*overflow-wrap: anywhere;/); + // break-word, not anywhere: anywhere breaks mid-word, which is how a + // squeezed pill ends up one character per line. + expect(source).toMatch(/\.rating-tag \{[^}]*overflow-wrap: break-word;/); + expect(source).not.toMatch(/\.rating-tag \{[^}]*overflow-wrap: anywhere;/); // No rule anywhere puts it back on one line. expect(source).not.toMatch(/\.rating-tag \{[^}]*white-space: nowrap;/); } expect(UPLOAD).toMatch(/\.tile-nsfw-row \{[^}]*min-width: 0/); }); + + // Wrapped to three or more lines, the pill token clamps to half the box height + // and the end caps' arc lands inside the text's own inset, so the first and + // last lines run flush against the border. A fixed radius holds through any + // number of lines, and at one line the two are the same shape. + it('caps the pill radius rather than following the box height', () => { + for (const source of [UPLOAD, EDIT]) { + expect(source).toMatch(/\.rating-tag \{[^}]*border-radius: 12px;/); + expect(source).not.toMatch(/\.rating-tag \{[^}]*border-radius: var\(--radius-pill\)/); + } + }); + + // Where the column has room for all four, the two rating items give up the + // difference rather than pushing "Mark it NSFW" onto a second row: flex breaks + // a line on each item's CONTENT size however shrinkable it is, so a zero basis + // is what takes them out of that decision. The floor is what makes that safe — + // keyed to the window instead, the same rule ran in a 219px column and shrank + // both items to about one character per line. + it('shrinks the two rating items only where the column has room', () => { + for (const source of [UPLOAD, EDIT]) { + expect(source).toMatch( + /@container admin-form \(min-width: 560px\) \{\s+\.nsfw-row \.rating-tag,\s+\.nsfw-row :global\(\.tag-rating-note\) \{\s+flex: 1 1 0;\s+min-width: 14ch;\s+max-width: max-content;/ + ); + // Nothing shrinks off a viewport query any more. + expect(source).not.toMatch(/@media \([^)]*\) \{\s+\.nsfw-row \.rating-tag,/); + } + // The column each form names, and the tile row is not one of them. + expect(EDIT).toMatch( + /\.edit-form \{[\s\S]{0,400}?container-type: inline-size;\s+container-name: admin-form;/ + ); + expect(UPLOAD).toMatch( + /\.upload-form \{[\s\S]{0,400}?container-type: inline-size;\s+container-name: admin-form;/ + ); + expect(UPLOAD).not.toMatch(/\.tile-nsfw-row[\s\S]{0,200}?container-type/); + }); }); describe('what a lookup says out loud', () => { diff --git a/src/lib/artist-lookup.test.ts b/src/lib/artist-lookup.test.ts index 739fed9f..07f3fca0 100644 --- a/src/lib/artist-lookup.test.ts +++ b/src/lib/artist-lookup.test.ts @@ -749,6 +749,75 @@ describe('statusLineKind', () => { } }); + // A second lookup keeps the fields the first one filled until its own result + // lands, so a result with no post or no date empties one of them THEN. Saying + // it was "left as it was" is a false report of a field the operator just + // watched go blank (4.1.3). + it('says a deferred field was emptied rather than left alone', () => { + expect( + statusLineKind({ sourcePostUrl: 'u' }, { cleared: { commissionedAt: true } }) + ).toBe('url_and_date_emptied'); + expect( + statusLineKind({ commissionedAt: 'd' }, { cleared: { sourcePostUrl: true } }) + ).toBe('date_and_url_emptied'); + // Nothing filled at all: a no-match, or a result the operator's own typing + // left no room for. + expect( + statusLineKind({}, { cleared: { sourcePostUrl: true, commissionedAt: true } }) + ).toBe('both_emptied'); + expect(statusLineKind({}, { cleared: { sourcePostUrl: true } })).toBe('url_emptied'); + expect(statusLineKind({}, { cleared: { commissionedAt: true } })).toBe('date_emptied'); + // An empty record changes nothing about the sentences that were there. + expect(statusLineKind({ sourcePostUrl: 'u' }, { cleared: {} })).toBe('url_only'); + expect(statusLineKind({ sourcePostUrl: 'u', commissionedAt: 'd' }, { cleared: {} })).toBe( + 'both' + ); + }); + + // The clash sentence already says the URL was left empty, which is exactly + // what emptying it leaves behind — so only the date needs the new kinds. + it('keeps the clash sentence over an emptied URL', () => { + expect( + statusLineKind({ commissionedAt: 'd' }, { clash: true, cleared: { sourcePostUrl: true } }) + ).toBe('clash'); + // A clash that carries no date either: the clash sentence claims a date it + // did not fill, so the emptied one is what is left to say. + expect( + statusLineKind( + {}, + { clash: true, cleared: { sourcePostUrl: true, commissionedAt: true } } + ) + ).toBe('both_emptied'); + }); + + it('names the emptied field in the sentence, in both locales', () => { + expect(m.admin_lookup_status_url_and_date_emptied({ site: 'Twitter' }, { locale: 'en' })).toBe( + 'Sona filled the source post URL from the Twitter post and emptied the commissioned date the last lookup filled, because that post carries no date. You can change the URL before you save.' + ); + expect(m.admin_lookup_status_url_emptied({}, { locale: 'en' })).toBe( + 'Sona emptied the source post URL the last lookup filled, because this result has no post to put there.' + ); + for (const line of [ + m.admin_lookup_status_url_and_date_emptied({ site: 'Twitter' }, { locale: 'ja' }), + m.admin_lookup_status_date_and_url_emptied({ site: 'Twitter' }, { locale: 'ja' }), + m.admin_lookup_status_both_emptied({}, { locale: 'ja' }), + m.admin_lookup_status_url_emptied({}, { locale: 'ja' }), + m.admin_lookup_status_date_emptied({}, { locale: 'ja' }) + ]) { + expect(line).toContain('空にしました'); + } + // None of them claims a field was left alone, which is what they replace. + for (const locale of ['en', 'ja'] as const) { + for (const key of [ + m.admin_lookup_status_both_emptied, + m.admin_lookup_status_url_emptied, + m.admin_lookup_status_date_emptied + ]) { + expect(key({}, { locale })).not.toMatch(/left as it was|そのままに/); + } + } + }); + // url_only has two causes and the kind cannot tell them apart: the post // carried no date, or the date field already held one. The sentence used to // assert the first ("That post has no date"), which is a false claim in the diff --git a/src/lib/artist-lookup.ts b/src/lib/artist-lookup.ts index 859c7501..dc00aafb 100644 --- a/src/lib/artist-lookup.ts +++ b/src/lib/artist-lookup.ts @@ -468,6 +468,14 @@ export interface LookupEdited { commissionedAt?: boolean; } +/** Which of the two fields this result EMPTIED: the previous lookup filled it, + * the operator never typed over it, and the new result has nothing to put in + * its place. A field can never be both filled and emptied by one result. */ +export interface LookupCleared { + sourcePostUrl?: boolean; + commissionedAt?: boolean; +} + /** Which sentence describes what the prefill actually did. */ export type StatusLineKind = | 'both' @@ -477,6 +485,11 @@ export type StatusLineKind = | 'date_kept' | 'clash' | 'clash_kept' + | 'url_and_date_emptied' + | 'date_and_url_emptied' + | 'both_emptied' + | 'url_emptied' + | 'date_emptied' | 'none'; /** @@ -492,23 +505,46 @@ export type StatusLineKind = * empty" is true only of a field that WAS empty. `urlHeld` says it is not, and * picks `clash_kept`, which claims the date and says the URL was not filled * without claiming it is empty. + * + * `cleared` is the other half of the same honesty. A second lookup keeps the + * fields the first one filled until its own result lands, so a result with no + * post or no date EMPTIES one of them then (SONA-220) — and `url_only` saying + * the date was "left as it was" would be a false report of a field the operator + * just watched go blank. The emptied kinds name it instead. */ export function statusLineKind( filled: LookupFields, - options: { clash?: boolean; edited?: LookupEdited; urlHeld?: boolean } = {} + options: { + clash?: boolean; + edited?: LookupEdited; + urlHeld?: boolean; + cleared?: LookupCleared; + } = {} ): StatusLineKind { const edited = options.edited ?? {}; + const cleared = options.cleared ?? {}; const urlFilled = filled.sourcePostUrl !== undefined; const dateFilled = filled.commissionedAt !== undefined; const url = urlFilled && !edited.sourcePostUrl; const date = dateFilled && !edited.commissionedAt; if (options.clash) { - if (!date) return 'none'; - return options.urlHeld ? 'clash_kept' : 'clash'; + // The clash sentence already says the URL was left empty, which is what + // emptying it leaves behind, so only the date needs the extra kinds below. + if (date) return options.urlHeld ? 'clash_kept' : 'clash'; + } else { + if (url && date) return 'both'; + if (url) { + if (cleared.commissionedAt) return 'url_and_date_emptied'; + return dateFilled ? 'url_kept' : 'url_only'; + } + if (date) { + if (cleared.sourcePostUrl) return 'date_and_url_emptied'; + return urlFilled ? 'date_kept' : 'date_only'; + } } - if (url && date) return 'both'; - if (url) return dateFilled ? 'url_kept' : 'url_only'; - if (date) return urlFilled ? 'date_kept' : 'date_only'; + if (cleared.sourcePostUrl && cleared.commissionedAt) return 'both_emptied'; + if (cleared.sourcePostUrl) return 'url_emptied'; + if (cleared.commissionedAt) return 'date_emptied'; return 'none'; } diff --git a/src/lib/components/ArtistLookupPanel.svelte b/src/lib/components/ArtistLookupPanel.svelte index 7667b1af..e024964f 100644 --- a/src/lib/components/ArtistLookupPanel.svelte +++ b/src/lib/components/ArtistLookupPanel.svelte @@ -26,6 +26,7 @@ siteLabel, statusLineKind, type ArtistChoice, + type LookupCleared, type LookupEdited, type LookupFields, type LookupMatch, @@ -51,6 +52,10 @@ * line — the seed is subject to the same never-overwrite rule, so the * sentence has to say which of the two fields it actually filled. */ seeded?: NewArtistSeed; + /** Which of the two fields this result EMPTIED, because the last lookup + * filled them and this one has nothing to put in their place (SONA-220). + * A snapshot like `filled`: the sentence says what this result did. */ + cleared?: LookupCleared; /** `edited`, for the seeded fields — same rule, same reason. */ seedEdited?: SeedEdited; /** Whether the source post URL field held anything when the prefill ran. @@ -84,6 +89,7 @@ fileName = '', filled = {}, edited = {}, + cleared = {}, seeded = {}, seedEdited = {}, sourceUrlHeld = false, @@ -117,7 +123,12 @@ const clash = $derived(data?.sourceClash ?? null); const siteCount = $derived(data ? new Set(data.matches.map((x) => x.site)).size : 0); const statusKind = $derived( - statusLineKind(filled, { clash: !!clash, edited, urlHeld: sourceUrlHeld }) + statusLineKind(filled, { clash: !!clash, edited, urlHeld: sourceUrlHeld, cleared }) + ); + // The sentences that only report an emptied field name no site, and the + // result that empties one can be a no-match with no prefill match to name. + const emptiedOnly = $derived( + statusKind === 'both_emptied' || statusKind === 'url_emptied' || statusKind === 'date_emptied' ); const seedKind = $derived(seedStatusKind(seeded, seedEdited)); // The "Sets the artist to {name}." sentence and the button it @@ -369,12 +380,26 @@ {/if} {/if} - {#if statusKind !== 'none' && prefill} + {#if statusKind !== 'none' && (prefill || emptiedOnly)}

- {#if statusKind === 'both'} + {#if statusKind === 'both_emptied'} + {m.admin_lookup_status_both_emptied()} + {:else if statusKind === 'url_emptied'} + {m.admin_lookup_status_url_emptied()} + {:else if statusKind === 'date_emptied'} + {m.admin_lookup_status_date_emptied()} + {:else if !prefill} + + {:else if statusKind === 'both'} {m.admin_lookup_status_both({ site: siteLabel(prefill.site) })} {:else if statusKind === 'url_only'} {m.admin_lookup_status_url_only({ site: siteLabel(prefill.site) })} + {:else if statusKind === 'url_and_date_emptied'} + {m.admin_lookup_status_url_and_date_emptied({ site: siteLabel(prefill.site) })} + {:else if statusKind === 'date_and_url_emptied'} + {m.admin_lookup_status_date_and_url_emptied({ site: siteLabel(prefill.site) })} {:else if statusKind === 'url_kept'} {m.admin_lookup_status_url_kept({ site: siteLabel(prefill.site) })} {:else if statusKind === 'date_kept'} diff --git a/src/routes/admin/images/[id]/edit/+page.svelte b/src/routes/admin/images/[id]/edit/+page.svelte index 9d14cce5..14a1eec8 100644 --- a/src/routes/admin/images/[id]/edit/+page.svelte +++ b/src/routes/admin/images/[id]/edit/+page.svelte @@ -19,6 +19,7 @@ runLookup, sentAfterApplyThrew, strictestRating, + type LookupCleared, type LookupFields, type LookupSite, type LookupState, @@ -70,6 +71,11 @@ // types: clearing a pasted URL afterwards would make the panel say Sona left // the field empty, which the operator did, not Sona. let lookupUrlHeld = $state(false); + // The other half of that record: which of the two fields a result EMPTIED, + // because the last lookup filled it and this one has no post or no date to + // put back (SONA-220). Without it the status line says a field was left as it + // was while the operator watched it go blank (4.1.3). + let lookupCleared = $state({}); // Read once, like every other form seed on this page: these are the values // the form OPENS with, and a later `data` change must not throw away what the // operator has typed. untrack is the documented spelling for that. @@ -175,6 +181,7 @@ twitterTagged = false; furaffinityTagged = false; lookupUrlHeld = false; + lookupCleared = {}; appliedArtist = null; // The tag-suggestion state rides along: the accepted tags, the rating // entail.dev returned for the PREVIOUS image, and its NSFW box (SONA-220). @@ -219,15 +226,19 @@ undefined ); - /** Undo what the PREVIOUS lookup wrote, but only where the operator has not - * typed over it since — the tag is the record of that. Without this a second - * lookup reads the first one's URL as operator-typed, fills nothing, and - * leaves a "From lookup" tag on a value from the other post. The upload - * page's resetSharedPrefill, plus the inline new-artist fields this page - * owns. */ + /** What the last lookup put on the page OUTSIDE the two prefilled fields, + * undone the moment a new search starts: none of it is recoverable from the + * result on its way, and what it says is about a search that is over. + * + * The source post URL and the commissioned date are the exception. Blanking + * them for the length of the round trip tears down everything downstream of + * the URL — the suggestion control drops its standing chips and its rating + * the moment that field changes (SONA-220) — and a lookup that comes back + * with the same post, or fails outright, then has nothing to put back. They + * are replaced, or emptied, in applyPrefill when the result lands, which is + * also when the status line can say so. The upload page's resetSharedResult, + * plus the inline new-artist fields this page owns. */ function resetLookupPrefill() { - if (sourceTagged) sourcePostUrl = ''; - if (dateTagged) commissionedAt = ''; if (nameTagged) artistName = ''; if (twitterTagged) newTwitter = ''; if (furaffinityTagged) newFuraffinity = ''; @@ -236,14 +247,13 @@ // Patreon, Instagram) are uncontrolled — anything typed there goes with // the form. An empty required form is a smaller cost than lost typing, // and the clearing announcement in startLookup says what happened. - sourceTagged = false; - dateTagged = false; nameTagged = false; twitterTagged = false; furaffinityTagged = false; lookupFilled = {}; lookupSeeded = {}; lookupUrlHeld = false; + lookupCleared = {}; appliedArtist = null; // A clash carried into the parent select belongs to the lookup that found // it, so a second lookup must not leave the first one's piece on offer. The @@ -318,20 +328,51 @@ * duplicate artist behind the operator's back and re-credit a piece that * already has one. */ function applyPrefill(next: LookupState) { + // A failure, or a search cancelled back to idle, leaves both fields exactly + // as they are: there is no new post to describe them, and what the last + // lookup wrote is still the best thing the page knows. if (next.kind !== 'results') return; // A new result describes a new seed, even when that seed is empty. lookupSeeded = {}; - lookupUrlHeld = sourcePostUrl.trim() !== ''; - const fields = prefillForResult(next.data, { sourcePostUrl, commissionedAt }); + // A field the LAST prefill wrote and the operator has not typed over since + // is still the lookup's to replace, so this result reads it as empty and + // fills it. Only the tag can tell the two apart, which is why the value is + // kept until here rather than blanked when the search started — and why the + // clash sentence reads a still-tagged value as nobody's rather than as the + // operator's. + const ownSource = sourceTagged ? '' : sourcePostUrl; + const ownDate = dateTagged ? '' : commissionedAt; + lookupUrlHeld = ownSource.trim() !== ''; + const fields = prefillForResult(next.data, { + sourcePostUrl: ownSource, + commissionedAt: ownDate + }); lookupFilled = fields; + const cleared: LookupCleared = {}; if (fields.sourcePostUrl !== undefined) { + // Assigned plainly. A second lookup that lands on the SAME post writes + // the same string, and $state only notifies on a value that differs, so + // the suggestion control keeps the chips and the rating it is holding + // rather than answering a change that did not happen. sourcePostUrl = fields.sourcePostUrl; sourceTagged = true; + } else if (sourceTagged) { + // This result has no post to offer — no match, or a clash whose URL + // belongs to another piece — so the last one's URL goes now, and the + // status line says it was emptied rather than left alone. + sourcePostUrl = ''; + sourceTagged = false; + cleared.sourcePostUrl = true; } if (fields.commissionedAt !== undefined) { commissionedAt = fields.commissionedAt; dateTagged = true; + } else if (dateTagged) { + commissionedAt = ''; + dateTagged = false; + cleared.commissionedAt = true; } + lookupCleared = cleared; } /** The inline new-artist form obeys the same rule as the two fields above: @@ -552,6 +593,7 @@ {lookup} filled={lookupFilled} edited={lookupEdited} + cleared={lookupCleared} seeded={artistMode === 'new' ? lookupSeeded : {}} seedEdited={lookupSeedEdited} sourceUrlHeld={lookupUrlHeld} @@ -952,6 +994,11 @@ flex-direction: column; gap: 20px; max-width: 600px; + /* The NSFW row's wrapping is about this column's width, not the window's + (SONA-220). Safe to contain: the width comes from the grid track and the + max-width above, never from the content. */ + container-type: inline-size; + container-name: admin-form; } .tags-field:has(:global(.tag-tray)) { @@ -1063,21 +1110,42 @@ /* The text grows with the number of sites, so the pill wraps rather than pushing the document into a sideways scroll. Kept on one line it cannot - shrink at all, which on this narrower form pushed the suggestion's own - note and its button onto a second row while the upload form kept them - inline at the same width. */ + shrink at all, and this form's column is 600px wide: the pill would rather + widen the row than break. */ .rating-tag { font-family: var(--font-primary); font-size: 11px; color: var(--muted-foreground); border: 1px solid var(--border); - border-radius: var(--radius-pill); + /* A fixed radius, not the pill token. Wrapped to three or more lines the + token clamps to half the box height, which pulls the end caps' arc inside + the text's own inset and leaves the first and last lines running flush + against the border. At one line the two are identical. */ + border-radius: 12px; padding: 1px 8px; white-space: normal; - overflow-wrap: anywhere; + overflow-wrap: break-word; max-width: 100%; } + /* With room for all four, this row holds one line and the two rating items + give up the difference instead of pushing "Mark it NSFW" onto a second row. + Flex picks its line breaks from each item's CONTENT size however shrinkable + it is, so a zero basis is what takes the two out of that decision; the cap + keeps either from stretching past its own text. The floor is what makes it + safe: without one, a column narrower than the row shrinks both items until + the text is a character per line, and with one an overfull row drops the + button to a second line the way it always did. Asked of the column, not the + window — this form's column is 219px wide in an 800px window. */ + @container admin-form (min-width: 560px) { + .nsfw-row .rating-tag, + .nsfw-row :global(.tag-rating-note) { + flex: 1 1 0; + min-width: 14ch; + max-width: max-content; + } + } + .artist-toggle { display: flex; gap: 4px; diff --git a/src/routes/admin/upload/+page.svelte b/src/routes/admin/upload/+page.svelte index ccf79ea8..0e1801de 100644 --- a/src/routes/admin/upload/+page.svelte +++ b/src/routes/admin/upload/+page.svelte @@ -23,6 +23,7 @@ lookupSentFile, sentAfterApplyThrew, withCreatedArtist, + type LookupCleared, type LookupFailReason, type LookupFields, type LookupMatch, @@ -510,6 +511,11 @@ // operator types: clearing a pasted URL afterwards would make the panel say // Sona left the field empty, which the operator did, not Sona. let sharedUrlHeld = $state(false); + // The other half of that record: which of the two fields this result EMPTIED, + // because the last lookup filled it and this one has no post or no date to + // put back (SONA-220). Without it the status line says a field was left as it + // was while the operator watched it go blank (4.1.3). + let sharedCleared = $state({}); const sharedEdited = $derived({ sourcePostUrl: sharedFilled.sourcePostUrl !== undefined && !sourceTagged, commissionedAt: sharedFilled.commissionedAt !== undefined && !dateTagged @@ -804,6 +810,7 @@ dateTagged = false; sharedFilled = {}; sharedUrlHeld = false; + sharedCleared = {}; resetSharedResult(); } @@ -845,30 +852,35 @@ commissionedAt: ownDate }); sharedFilled = fields; + const cleared: LookupCleared = {}; if (fields.sourcePostUrl !== undefined) { - // Written only when it is really different. Re-assigning the same URL - // still counts as a change to everything watching the field, and the - // suggestion control answers one by dropping the chips and the rating it - // is holding — for a second lookup that landed on the same post. - if (sourcePostUrl !== fields.sourcePostUrl) sourcePostUrl = fields.sourcePostUrl; + // Assigned plainly. A second lookup that lands on the SAME post writes + // the same string, and $state only notifies on a value that differs, so + // the suggestion control keeps the chips and the rating it is holding + // rather than answering a change that did not happen. + sourcePostUrl = fields.sourcePostUrl; sourceTagged = true; wrote.sourcePostUrl = true; } else if (sourceTagged) { // This result has no post to offer — no match, or a clash whose URL // belongs to another piece — so the last one's URL goes now. Deferred to // here rather than done at the start: until the result was in, there was - // no way to know it would not be refilled. + // no way to know it would not be refilled. Recorded, because the status + // line has to say the field was emptied instead of left alone. sourcePostUrl = ''; sourceTagged = false; + cleared.sourcePostUrl = true; } if (fields.commissionedAt !== undefined) { - if (commissionedAt !== fields.commissionedAt) commissionedAt = fields.commissionedAt; + commissionedAt = fields.commissionedAt; dateTagged = true; wrote.commissionedAt = true; } else if (dateTagged) { commissionedAt = ''; dateTagged = false; + cleared.commissionedAt = true; } + sharedCleared = cleared; return wrote; } @@ -1430,6 +1442,7 @@ fileName={tiles.length > 1 ? (parentTile?.fileName ?? '') : ''} filled={sharedFilled} edited={sharedEdited} + cleared={sharedCleared} sourceUrlHeld={sharedUrlHeld} {appliedArtist} privateNotice={sharedSentPrivate && lookupSentFile(sharedLookup)} @@ -1630,6 +1643,12 @@ flex-direction: column; gap: 20px; max-width: 800px; + /* The NSFW row's wrapping is about this column's width, not the window's + (SONA-220). Safe to contain: the width comes from the page and the + max-width above, never from the content, and the new-artist dialog with + its fixed overlay is mounted outside this form. */ + container-type: inline-size; + container-name: admin-form; } .upload-form h2 { @@ -2114,13 +2133,35 @@ font-size: 11px; color: var(--muted-foreground); border: 1px solid var(--border); - border-radius: var(--radius-pill); + /* A fixed radius, not the pill token. Wrapped to three or more lines the + token clamps to half the box height, which pulls the end caps' arc inside + the text's own inset and leaves the first and last lines running flush + against the border. At one line the two are identical. */ + border-radius: 12px; padding: 1px 8px; white-space: normal; - overflow-wrap: anywhere; + overflow-wrap: break-word; max-width: 100%; } + /* With room for all four, this row holds one line and the two rating items + give up the difference instead of pushing "Mark it NSFW" onto a second row. + Flex picks its line breaks from each item's CONTENT size however shrinkable + it is, so a zero basis is what takes the two out of that decision; the cap + keeps either from stretching past its own text. The floor is what makes it + safe: without one, a column narrower than the row shrinks both items until + the text is a character per line, and with one an overfull row drops the + button to a second line the way it always did. Asked of the column, not the + window — this form's column is 219px wide in an 800px window. */ + @container admin-form (min-width: 560px) { + .nsfw-row .rating-tag, + .nsfw-row :global(.tag-rating-note) { + flex: 1 1 0; + min-width: 14ch; + max-width: max-content; + } + } + .field-label { font-size: 14px; font-weight: 500; diff --git a/tests/e2e/artist-lookup.spec.ts b/tests/e2e/artist-lookup.spec.ts index e94407f2..dc92b889 100644 --- a/tests/e2e/artist-lookup.spec.ts +++ b/tests/e2e/artist-lookup.spec.ts @@ -54,65 +54,62 @@ function matchedBody(over: Record = {}) { // suggestion control refusing and only one pill on the page. const X_POST = 'https://x.com/kuttoya/status/1789012345678901234'; +const X_MATCH = { + site: 'Twitter', + siteId: '1789012345678901234', + handles: ['kuttoya'], + distance: 0, + band: 'exact', + postedAt: '2026-03-04T10:00:00Z', + rating: 'adult', + postUrl: X_POST +}; + /** One confident match on that X post, rated adult, so the lookup fills the - * source field with a URL the suggestion pill will accept. */ -function xMatchBody() { - return matchedBody({ - matches: [ - { - site: 'Twitter', - siteId: '1789012345678901234', - handles: ['kuttoya'], - distance: 0, - band: 'exact', - postedAt: '2026-03-04T10:00:00Z', - rating: 'adult', - postUrl: X_POST - } - ] - }); + * source field with a URL the suggestion pill will accept. `over` changes that + * one match, which is how the repeat-lookup tests name another post without + * restating the other seven fields. */ +function xMatchBody(over: Record = {}) { + return matchedBody({ matches: [{ ...X_MATCH, ...over }] }); } // A second X status, for the case where a repeat lookup lands somewhere else. const X_POST_2 = 'https://x.com/kuttoya/status/1789012345678909999'; +const X_SITE_ID_2 = '1789012345678909999'; +/** The same match on that second post: what a repeat lookup that lands + * elsewhere comes back with. */ +const SECOND_POST = { + siteId: X_SITE_ID_2, + postUrl: X_POST_2, + postedAt: '2026-03-05T10:00:00Z' +}; + +/** The clash row itself: a piece that already claims the matched post. */ +function sourceClash(imageId: number, title: string) { + return { + imageId, + title, + isVariant: false, + parentImageId: null, + variantCount: 0, + thumbnailUrl: null, + artistName: 'Test Artist', + uploadedAt: '2026-07-09T00:00:00.000Z', + width: 1200, + height: 900 + }; +} /** The X match with a source clash on it: the post already belongs to another * piece, so the prefill deliberately leaves the source URL alone. */ function xClashBody(imageId: number, title: string) { - return { - ...xMatchBody(), - sourceClash: { - imageId, - title, - isVariant: false, - parentImageId: null, - variantCount: 0, - thumbnailUrl: null, - artistName: 'Test Artist', - uploadedAt: '2026-07-09T00:00:00.000Z', - width: 1200, - height: 900 - } - }; + return { ...xMatchBody(), sourceClash: sourceClash(imageId, title) }; } /** The same confident match, plus a source clash on a piece the page never * loaded an option for. */ function clashBody(imageId: number, title: string) { - return matchedBody({ - sourceClash: { - imageId, - title, - isVariant: false, - parentImageId: null, - variantCount: 0, - thumbnailUrl: null, - artistName: 'Test Artist', - uploadedAt: '2026-07-09T00:00:00.000Z', - width: 1200, - height: 900 - } - }); + return matchedBody({ sourceClash: sourceClash(imageId, title) }); } async function stubLookup(page: Page, body: unknown, status = 200) { @@ -644,15 +641,74 @@ test.describe('with a key saved', () => { await expect(nsfw).toHaveAccessibleDescription(/Rated explicit by entail.dev/); await expect(nsfw).not.toBeChecked(); - // Both pills fit on the row itself at this width. "Mark it NSFW" does not: - // the two forms hold the same row in different columns (this one is 600px - // wide, the upload form 800px), and the four items want about 606px here, - // so the button wraps on this page and not on that one. Left as an open - // question rather than pinned either way — closing it means changing how - // wide this whole form is, which is a layout decision about every field on - // the page and not something a rating pill gets to settle. + // All four items hold one row at this width. They want about 606px in a + // 600px column, and flex breaks a line on content size however shrinkable + // an item is, so "Mark it NSFW" used to drop to a row of its own here while + // the upload form, 800px wide, kept it inline. The two pills give up the + // difference instead and wrap their own text (SONA-220). await expect(page.locator('#lookup-rating-tag')).toBeVisible(); await expect(page.locator('#tags-rating')).toBeVisible(); + // Compared by centre, not by top: the row centres its items, and the button + // is 36px tall beside an 18px label, so equal tops would be the wrong test + // for "same row" — they were never equal, even before the button wrapped. + const centre = async (selector: string) => { + const box = await page.locator(selector).boundingBox(); + if (!box) throw new Error(`${selector} has no box`); + return box.y + box.height / 2; + }; + const labelCentre = await centre('.tag-check-row .checkbox-label'); + for (const selector of [ + '#lookup-rating-tag', + '#tags-rating', + '.tag-check-row .btn' + ]) { + expect(Math.abs((await centre(selector)) - labelCentre)).toBeLessThanOrEqual(1); + } + await expect(page.locator('.tag-check-row .btn')).toHaveText('Mark it NSFW'); + // And the phone is untouched by that: shrinking the pills is a rule for the + // wide row only, so at 320 the row still wraps whole items, each onto its + // own line under the label text rather than under the checkbox. + await page.setViewportSize({ width: 320, height: 900 }); + const label = await page.locator('.tag-check-row .checkbox-label').boundingBox(); + if (!label) throw new Error('the checkbox label has no box'); + for (const selector of [ + '#lookup-rating-tag', + '#tags-rating', + '.tag-check-row .btn' + ]) { + const box = await page.locator(selector).boundingBox(); + if (!box) throw new Error(`${selector} has no box`); + expect(box.y).toBeGreaterThan(label.y); + expect(box.x).toBeCloseTo(label.x + 24, 0); + } + // Wrapped rather than pushed: the page still does not scroll sideways. + const overflow = await page.evaluate(() => { + const el = document.scrollingElement; + return el ? el.scrollWidth - el.clientWidth : 0; + }); + expect(overflow).toBeLessThanOrEqual(0); + + // 900px: the layout is still two columns here, so this form's column is + // 284px — far narrower than the window. That is the range the row's + // wrapping has to be keyed to. A viewport-keyed shrink rule let both + // rating items collapse to about one character per line in that column. + await page.setViewportSize({ width: 900, height: 900 }); + for (const selector of ['#lookup-rating-tag', '#tags-rating']) { + const box = await page.locator(selector).boundingBox(); + if (!box) throw new Error(`${selector} has no box`); + // One line is 18px for the pill and 16px for the note, and each extra + // line adds about the same again, so 60px is inside three lines. The + // shattered state measured 88px and taller. + expect(box.height).toBeLessThanOrEqual(60); + // And no item is squeezed under its floor, which is what shattered the + // text in the first place. + expect(box.width).toBeGreaterThanOrEqual(60); + } + const narrowOverflow = await page.evaluate(() => { + const el = document.scrollingElement; + return el ? el.scrollWidth - el.clientWidth : 0; + }); + expect(narrowOverflow).toBeLessThanOrEqual(0); }); // ---- The Source Post URL field's own two descriptions -------------------- @@ -770,23 +826,7 @@ test.describe('with a key saved', () => { await suggestPill(page).click(); await expect(page.locator('.tag-chip')).toHaveCount(3); - await stubLookup( - page, - matchedBody({ - matches: [ - { - site: 'Twitter', - siteId: '1789012345678909999', - handles: ['kuttoya'], - distance: 0, - band: 'exact', - postedAt: '2026-03-05T10:00:00Z', - rating: 'adult', - postUrl: X_POST_2 - } - ] - }) - ); + await stubLookup(page, xMatchBody(SECOND_POST)); await pill(page).click(); await expect(sourceInput(page)).toHaveValue(X_POST_2); @@ -849,6 +889,129 @@ test.describe('with a key saved', () => { await expect(page.locator('#source-lookup-tag')).toHaveCount(0); }); + // The branch that empties the commissioned date had no test of its own: a + // second result that carries a post but no date leaves the field the first one + // filled with nothing to refill it, so it goes when the result lands — and the + // panel's role="status" has to say so rather than "left the date as it was". + test('a repeat lookup on a dateless post empties the date it filled', async ({ page }) => { + await stubLookup(page, xMatchBody()); + await oneDoneTile(page); + + await pill(page).click(); + await expect(dateInput(page)).toHaveValue('2026-03-04'); + await expect(page.locator('#commissioned-lookup-tag')).toBeVisible(); + + await stubLookup(page, xMatchBody({ ...SECOND_POST, postedAt: null })); + await pill(page).click(); + + // The URL took the new post, and the date it can no longer stand behind is + // empty rather than left describing the old one. + await expect(sourceInput(page)).toHaveValue(X_POST_2); + await expect(dateInput(page)).toHaveValue(''); + await expect(page.locator('#commissioned-lookup-tag')).toHaveCount(0); + await expect(page.locator('#source-lookup-tag')).toHaveText('From lookup'); + await expect(panel(page)).toContainText( + 'Sona filled the source post URL from the Twitter post and emptied the commissioned date the last lookup filled, because that post carries no date.' + ); + await expect(panel(page)).not.toContainText('left the commissioned date as it was'); + }); + + // The same three cases on the edit page, which ran its full reset at the click + // until now: a repeat lookup there dropped the chips and the rating of a + // suggestion about the very post it was on its way back with. + test('the edit page keeps the suggested chips through a repeat lookup', async ({ page }) => { + await stubLookup(page, xMatchBody()); + await stubSuggestions(page, 200, { + source: 'x', + tags: ['mammal', 'canine', 'fox'], + rating: 'explicit', + imageCount: 1 + }); + await gotoEditHydrated(page); + + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(X_POST); + await suggestPill(page).click(); + await expect(page.locator('.tag-chip')).toHaveCount(3); + + await pill(page).click(); + await expect(panel(page)).toContainText('kuttoya'); + await expect(sourceInput(page)).toHaveValue(X_POST); + await expect(page.locator('.tag-chip')).toHaveCount(3); + await expect(page.locator('#tags-rating')).toHaveText('Rated explicit by entail.dev.'); + + // And a lookup that fails outright leaves the field as it was, rather than + // emptying it with nothing to put back. + await stubLookup(page, { enabled: true, error: 'rate_limited', forwarded: true }, 429); + await pill(page).click(); + await expect(panel(page)).toContainText( + 'FuzzySearch is limiting how often your site can search right now.' + ); + await expect(sourceInput(page)).toHaveValue(X_POST); + await expect(page.locator('#source-lookup-tag')).toHaveText('From lookup'); + await expect(page.locator('.tag-chip')).toHaveCount(3); + await expect(page.locator('#tags-status')).not.toContainText('set that lookup aside'); + }); + + test('the edit page replaces the URL when the repeat lookup lands elsewhere', async ({ + page + }) => { + await stubLookup(page, xMatchBody()); + await stubSuggestions(page, 200, { + source: 'x', + tags: ['mammal', 'canine', 'fox'], + rating: 'explicit', + imageCount: 1 + }); + await gotoEditHydrated(page); + + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(X_POST); + await suggestPill(page).click(); + await expect(page.locator('.tag-chip')).toHaveCount(3); + + await stubLookup(page, xMatchBody(SECOND_POST)); + await pill(page).click(); + + await expect(sourceInput(page)).toHaveValue(X_POST_2); + await expect(dateInput(page)).toHaveValue('2026-03-05'); + await expect(page.locator('#source-lookup-tag')).toHaveText('From lookup'); + // The suggestion was about the old post, so it goes with it — and says so. + await expect(page.locator('.tag-chip')).toHaveCount(0); + await expect(page.locator('#tags-rating')).toHaveCount(0); + await expect(page.locator('#tags-status')).toHaveText( + 'The source post URL changed, so Sona set that lookup aside.' + ); + }); + + test('the edit page empties both fields when the result has nothing to put back', async ({ + page + }) => { + await stubLookup(page, xMatchBody()); + await gotoEditHydrated(page); + + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(X_POST); + await expect(dateInput(page)).toHaveValue('2026-03-04'); + await expect(page.locator('#source-lookup-tag')).toBeVisible(); + await expect(page.locator('#commissioned-lookup-tag')).toBeVisible(); + + // Nothing confident enough to prefill from: the second result offers no + // post at all, so both fields the first one filled are emptied when it + // lands — and the panel names them rather than claiming they were kept. + await stubLookup(page, xMatchBody({ band: 'possible' })); + await pill(page).click(); + + await expect(sourceInput(page)).toHaveValue(''); + await expect(dateInput(page)).toHaveValue(''); + await expect(page.locator('#source-lookup-tag')).toHaveCount(0); + await expect(page.locator('#commissioned-lookup-tag')).toHaveCount(0); + await expect(panel(page)).toContainText( + 'Sona emptied the source post URL and commissioned date the last lookup filled, because this result has nothing to put there.' + ); + await expect(panel(page)).not.toContainText('left the source post URL as it was'); + }); + test('a refused key says so and offers Settings, not a retry', async ({ page }) => { await stubLookup(page, { enabled: true, error: 'key_refused', forwarded: true }, 424); await oneDoneTile(page); From 920a337cdab2ab08eec7781a2c4c11d78ecea4ad Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:49:02 -0700 Subject: [PATCH 03/18] fix(admin): clear stale lookup fields on a no-match result, and put the NSFW row's rules in one place (SONA-220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second lookup that finds nothing now empties the URL and date the previous lookup filled, drops their tags, and says so in the panel's no-match state. The status sentences for an emptied field say "cleared", the same verb as the panel's announcement, and no longer claim the result had no post when the post exists but belongs to another piece; a clash that empties the URL gets its own sentence. The Japanese strings use 検索 like the rest of the file. The searching announcement names the new artist details it clears, and a parent change on the upload form announces which shared fields it emptied. The NSFW row's container queries and the rating pill's rule live once in app.css, the two ranges abut, the wide row uses a 4px gap so all four items fit the 600px column, and the narrow row stacks so the 24px indent only lands on an item on its own line. The page-scoped copies are gone. --- messages/en.json | 18 +- messages/ja.json | 18 +- src/app.css | 68 ++++-- src/lib/artist-lookup-markup.test.ts | 136 +++++++++-- src/lib/artist-lookup.test.ts | 39 ++- src/lib/artist-lookup.ts | 8 + src/lib/components/ArtistLookupPanel.svelte | 31 ++- .../admin/images/[id]/edit/+page.svelte | 69 ++---- src/routes/admin/upload/+page.svelte | 110 ++++----- tests/e2e/artist-lookup.spec.ts | 231 +++++++++--------- 10 files changed, 445 insertions(+), 283 deletions(-) diff --git a/messages/en.json b/messages/en.json index 01cdb172..0063f110 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1980,14 +1980,15 @@ "admin_lookup_status_date_only": "Sona filled the commissioned date from the {site} post and left the source post URL as it was. You can change the date before you save.", "admin_lookup_status_clash": "Sona filled the commissioned date from the {site} post and left the source post URL empty, because that post is already the source of {title}. You can change the date before you save.", "admin_lookup_status_clash_kept": "Sona filled the commissioned date from the {site} post and left your source post URL as it was, because that post is already the source of {title}. You can change the date before you save.", + "admin_lookup_status_clash_emptied": "Sona cleared the source post URL the last lookup filled, because the {site} post Sona found is already the source of {title}. You can type one in before you save.", "admin_lookup_status_url_kept": "Sona filled the source post URL from the {site} post. You can change it before you save.", "admin_lookup_status_date_kept": "Sona filled the commissioned date from the {site} post. You can change it before you save.", "admin_lookup_status_kept": "Sona filled the commissioned date from the {site} post and left your source post URL as it was.", - "admin_lookup_status_url_and_date_emptied": "Sona filled the source post URL from the {site} post and emptied the commissioned date the last lookup filled, because that post carries no date. You can change the URL before you save.", - "admin_lookup_status_date_and_url_emptied": "Sona filled the commissioned date from the {site} post and emptied the source post URL the last lookup filled, because that post has no link to store. You can change the date before you save.", - "admin_lookup_status_both_emptied": "Sona emptied the source post URL and commissioned date the last lookup filled, because this result has nothing to put there.", - "admin_lookup_status_url_emptied": "Sona emptied the source post URL the last lookup filled, because this result has no post to put there.", - "admin_lookup_status_date_emptied": "Sona emptied the commissioned date the last lookup filled, because this result carries no date.", + "admin_lookup_status_url_and_date_emptied": "Sona filled the source post URL from the {site} post and cleared the commissioned date the last lookup filled, because that post has no date. You can change the URL before you save.", + "admin_lookup_status_date_and_url_emptied": "Sona filled the commissioned date from the {site} post and cleared the source post URL the last lookup filled, because this result has no link to put there. You can change the date before you save.", + "admin_lookup_status_both_emptied": "Sona cleared the source post URL and commissioned date the last lookup filled, because this lookup filled neither one.", + "admin_lookup_status_url_emptied": "Sona cleared the source post URL the last lookup filled, because this lookup filled nothing in its place. You can type one in before you save.", + "admin_lookup_status_date_emptied": "Sona cleared the commissioned date the last lookup filled, because this result has no date.", "admin_lookup_status_artist_hint": "Sets the artist to {name}.", "admin_lookup_status_seed_both": "Sona filled the new artist's name and {site} link. They're a guess until you check them, and you're the one publishing them.", "admin_lookup_status_seed_name": "Sona filled the new artist's name. It's a guess until you check it, and you're the one publishing it.", @@ -2037,8 +2038,11 @@ "admin_lookup_announce_new_form": "Switched to the new artist form. Add the artist by hand.", "admin_lookup_announce_form_already_open": "The new artist form is already open.", "admin_lookup_announce_seed_kept": "The new artist's fields already have values, so Sona left them alone.", - "admin_lookup_announce_searching_cleared": "Sona cleared the fields the last lookup filled.", + "admin_lookup_announce_searching_cleared": "Sona cleared the new artist details the last lookup filled.", "admin_lookup_announce_shared_refilled": "Sona filled the shared fields from the parent image's result.", "admin_lookup_announce_shared_refilled_source": "Sona filled the source post URL from the parent image's result.", - "admin_lookup_announce_shared_refilled_date": "Sona filled the commissioned date from the parent image's result." + "admin_lookup_announce_shared_refilled_date": "Sona filled the commissioned date from the parent image's result.", + "admin_lookup_announce_shared_cleared": "Sona cleared the source post URL and commissioned date the last parent's lookup filled.", + "admin_lookup_announce_shared_cleared_source": "Sona cleared the source post URL the last parent's lookup filled.", + "admin_lookup_announce_shared_cleared_date": "Sona cleared the commissioned date the last parent's lookup filled." } diff --git a/messages/ja.json b/messages/ja.json index 4de80fe5..3a5d926e 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -1579,14 +1579,15 @@ "admin_lookup_status_date_only": "{site}の投稿から制作依頼日を入力しました。投稿元URLはそのままにしています。保存前に日付を変更できます。", "admin_lookup_status_clash": "{site}の投稿から制作依頼日を入力しました。見つかった投稿はすでに「{title}」の投稿元になっているため、投稿元URLは空のままにしています。保存前に日付を変更できます。", "admin_lookup_status_clash_kept": "{site}の投稿から制作依頼日を入力しました。見つかった投稿はすでに「{title}」の投稿元になっているため、入力済みの投稿元URLはそのままにしています。保存前に日付を変更できます。", + "admin_lookup_status_clash_emptied": "Sonaが見つけた{site}の投稿はすでに{title}の投稿元であるため、前回の検索で入力した投稿元URLは消去しました。保存前に入力できます。", "admin_lookup_status_url_kept": "{site}の投稿から投稿元URLを入力しました。保存前に変更できます。", "admin_lookup_status_date_kept": "{site}の投稿から制作依頼日を入力しました。保存前に変更できます。", "admin_lookup_status_kept": "{site}の投稿から制作依頼日を入力しました。入力済みの投稿元URLはそのままにしています。", - "admin_lookup_status_url_and_date_emptied": "{site}の投稿から投稿元URLを入力しました。その投稿には日付がないため、前回のルックアップが入力した制作依頼日は空にしました。保存前にURLを変更できます。", - "admin_lookup_status_date_and_url_emptied": "{site}の投稿から制作依頼日を入力しました。その投稿には保存できるリンクがないため、前回のルックアップが入力した投稿元URLは空にしました。保存前に日付を変更できます。", - "admin_lookup_status_both_emptied": "今回の結果には入れられるものがないため、前回のルックアップが入力した投稿元URLと制作依頼日を空にしました。", - "admin_lookup_status_url_emptied": "今回の結果には入れられる投稿がないため、前回のルックアップが入力した投稿元URLを空にしました。", - "admin_lookup_status_date_emptied": "今回の結果には日付がないため、前回のルックアップが入力した制作依頼日を空にしました。", + "admin_lookup_status_url_and_date_emptied": "{site}の投稿から投稿元URLを入力しました。その投稿には日付がないため、前回の検索で入力した制作依頼日は消去しました。保存前にURLを変更できます。", + "admin_lookup_status_date_and_url_emptied": "{site}の投稿から制作依頼日を入力しました。今回の結果には入れられるリンクがないため、前回の検索で入力した投稿元URLは消去しました。保存前に日付を変更できます。", + "admin_lookup_status_both_emptied": "今回の検索はどちらの項目も入力しなかったため、前回の検索で入力した投稿元URLと制作依頼日は消去しました。", + "admin_lookup_status_url_emptied": "今回の検索は代わりの値を入力しなかったため、前回の検索で入力した投稿元URLは消去しました。保存前に入力できます。", + "admin_lookup_status_date_emptied": "今回の結果には日付がないため、前回の検索で入力した制作依頼日は消去しました。", "admin_lookup_status_artist_hint": "アーティストに{name}を設定します。", "admin_lookup_status_seed_both": "新しいアーティストの名前と{site}のリンクを入力しました。確認するまでは推測にすぎず、公開するのはあなたです。", "admin_lookup_status_seed_name": "新しいアーティストの名前を入力しました。確認するまでは推測にすぎず、公開するのはあなたです。", @@ -1636,8 +1637,11 @@ "admin_lookup_announce_new_form": "新しいアーティストのフォームに切り替えました。アーティストを手で入力してください。", "admin_lookup_announce_form_already_open": "新しいアーティストのフォームはすでに開いています。", "admin_lookup_announce_seed_kept": "新しいアーティストの各項目にはすでに値が入っているため、そのままにしました。", - "admin_lookup_announce_searching_cleared": "前回の検索で入力した項目は消去しました。", + "admin_lookup_announce_searching_cleared": "前回の検索で入力した新しいアーティストの情報は消去しました。", "admin_lookup_announce_shared_refilled": "親画像の検索結果から共有項目を入力しました。", "admin_lookup_announce_shared_refilled_source": "親画像の検索結果から投稿元URLを入力しました。", - "admin_lookup_announce_shared_refilled_date": "親画像の検索結果から制作依頼日を入力しました。" + "admin_lookup_announce_shared_refilled_date": "親画像の検索結果から制作依頼日を入力しました。", + "admin_lookup_announce_shared_cleared": "前の親画像の検索で入力した投稿元URLと制作依頼日は消去しました。", + "admin_lookup_announce_shared_cleared_source": "前の親画像の検索で入力した投稿元URLは消去しました。", + "admin_lookup_announce_shared_cleared_date": "前の親画像の検索で入力した制作依頼日は消去しました。" } diff --git a/src/app.css b/src/app.css index 3049da71..6e5ecfda 100644 --- a/src/app.css +++ b/src/app.css @@ -552,26 +552,64 @@ select.input { flex-wrap: wrap; } -/* Where this row breaks is decided by the COLUMN it sits in, not by the window. - Both admin forms hold it inside a grid column that is far narrower than the - viewport for most of the range — the edit form's column is 219px wide in an - 800px window — so a viewport query indented the phone and left the whole - middle of the range wrapping to the checkbox's edge. Both forms name - themselves `admin-form` instead, and this asks the column. */ -@container admin-form (max-width: 559px) { - /* Wrapped onto its own line, the note lines up under the label text rather - than under the checkbox. The lookup's rating pill (SONA-156) shares the - row and wraps the same way, so it takes the same indent — otherwise the - two pills wrap to two different left edges. CSS cannot ask whether an item - wrapped, so the indent is by column width alone: a short pill that still - fits inline just under 560px carries a 24px gap. That is what the note - beside it has always done, and matching it beats a third behaviour on one - row. */ +/* Where this row breaks is the COLUMN's business, not the window's: the edit + form's column is 219px wide in an 800px window, so a viewport query left the + whole middle of the range wrapping to the checkbox's edge. */ +@container admin-form not (min-width: 560px) { + /* Wrapped onto its own line, each of these lines up under the label text + rather than under the checkbox. CSS cannot ask whether an item wrapped, so + the indent goes by column width alone, as the note has always done. */ .tag-check-row .tag-rating-note, .tag-check-row .rating-tag, .tag-check-row .btn { margin-left: 24px; } + + /* Stacked rather than wrapped, which is what the mocks draw. Wrapping put the + 24px indent on items that had not wrapped, so an inline row carried 32px + gaps; stacking makes the indent true of every item that has it. */ + .tag-check-row { + flex-direction: column; + align-items: flex-start; + } +} + +/* The lookup's rating pill (SONA-156) — both admin forms and the upload tiles + hold the same one. A fixed radius, not the pill token: wrapped to three lines + the token clamps to half the box height and the end caps' arc lands inside the + text's own inset. */ +.rating-tag { + font-family: var(--font-primary); + font-size: 11px; + color: var(--muted-foreground); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1px 8px; + /* The text grows with the number of sites, so the pill wraps inside whatever + holds it rather than pushing the document into a sideways scroll. */ + white-space: normal; + overflow-wrap: break-word; + max-width: 100%; +} + +/* With room for all four, the two rating items give up the difference instead + of pushing "Mark it NSFW" onto a second row: flex breaks a line on content + size, so a zero basis takes them out of that decision and the floor keeps a + narrow column from shrinking them to a character per line. */ +@container admin-form (min-width: 560px) { + .tag-check-row .rating-tag, + .tag-check-row .tag-rating-note { + flex: 1 1 0; + min-width: 14ch; + max-width: max-content; + } + + /* Three gaps at 8px against an 8px shortfall: the edit form's row wants 608px + in a 600px column, and 4px buys back the 12px that keeps all four on one + line. The wider gap stays wherever the row has room for it. */ + .tag-check-row { + gap: 4px; + } } /* The pill that opens a lookup — the outline capsule the "Add New Artist" diff --git a/src/lib/artist-lookup-markup.test.ts b/src/lib/artist-lookup-markup.test.ts index a6f72dae..d4fdceba 100644 --- a/src/lib/artist-lookup-markup.test.ts +++ b/src/lib/artist-lookup-markup.test.ts @@ -537,7 +537,7 @@ describe('the "From lookup" tag', () => { // cannot drop a field it did not write. It still cannot carry a PREVIOUS // result's seed: applyPrefill empties it before the new one is described. expect(EDIT).toMatch( - /function applyPrefill\([\s\S]{0,600}?lookupSeeded = \{\};/ + /function applyPrefill\([\s\S]{0,1200}?lookupSeeded = \{\};/ ); expect(EDIT).toMatch( /const lookupSeedEdited = \$derived\(\{[\s\S]{0,300}?artistName: lookupSeeded\.artistName !== undefined && !nameTagged/ @@ -900,6 +900,32 @@ describe('what the lookup copy names', () => { } }); + // The mirror of those three. Moving the parent empties the two fields the + // last parent's lookup filled, and a parent with no result of its own puts + // nothing in the panel to say so (4.1.3). + it('names the cleared shared fields in both catalogs, and says them out loud', () => { + expect(ja.admin_lookup_announce_shared_cleared).toBeTruthy(); + expect(ja.admin_lookup_announce_shared_cleared_source).toContain('投稿元URL'); + expect(ja.admin_lookup_announce_shared_cleared_date).toContain('制作依頼日'); + for (const catalog of [en, ja]) { + const sentences = [ + catalog.admin_lookup_announce_shared_cleared, + catalog.admin_lookup_announce_shared_cleared_source, + catalog.admin_lookup_announce_shared_cleared_date + ]; + expect(new Set(sentences).size).toBe(3); + } + // The reset hands back what it emptied, and the parent move says it — + // only where nothing was written, because a refill has its own line and + // two announcements in one tick leave the region holding the second. + expect(UPLOAD).toMatch( + /function resetSharedPrefill\(\): LookupCleared \{[\s\S]{0,400}?emptied\.sourcePostUrl = true;[\s\S]{0,300}?emptied\.commissionedAt = true;[\s\S]{0,300}?return emptied;/ + ); + expect(UPLOAD).toMatch( + /function onParentChanged\(index: number\)[\s\S]{0,900}?if \(!wrote\.sourcePostUrl && !wrote\.commissionedAt\) \{[\s\S]{0,400}?m\.admin_lookup_announce_shared_cleared\(\)/ + ); + }); + // An unreadable file is a dead end on a variant tile: not retryable, no // Settings remedy, and the focus handler sends the operator to the artist // select. So the body has to end by telling them to type the name, the way @@ -998,9 +1024,12 @@ describe('what the lookup copy names', () => { // The panel's own status line already says the image is on its way to // FuzzySearch, so this one carries only what that line does not. + // It fires for the inline new-artist fields alone now: the two prefilled ones + // wait for the result and are reported by the panel's status line, so a line + // saying "the fields" named none of what the operator watched empty. it('carries the cleared-fields announcement in both catalogs, without the searching line', () => { expect(en.admin_lookup_announce_searching_cleared).toBe( - 'Sona cleared the fields the last lookup filled.' + 'Sona cleared the new artist details the last lookup filled.' ); expect(en.admin_lookup_announce_searching_cleared).not.toMatch(/FuzzySearch/); expect(ja.admin_lookup_announce_searching_cleared).toBeTruthy(); @@ -1178,23 +1207,48 @@ describe('focus after the panel goes away', () => { // refill it. Only the tag tells that from something the operator typed. expect(source).toMatch( new RegExp( - `function ${apply}\\([\\s\\S]{0,900}?const ownSource = sourceTagged \\? '' : sourcePostUrl;\\s+const ownDate = dateTagged \\? '' : commissionedAt;` + `function ${apply}\\([\\s\\S]{0,1600}?const ownSource = sourceTagged \\? '' : sourcePostUrl;\\s+const ownDate = dateTagged \\? '' : commissionedAt;` ) ); // And where the result offers nothing, the deferred field is emptied // there instead — recorded, so the status line can say so (4.1.3). expect(source).toMatch( new RegExp( - `function ${apply}\\([\\s\\S]{0,2000}?\\} else if \\(sourceTagged\\) \\{[\\s\\S]{0,600}?sourcePostUrl = '';\\s+sourceTagged = false;\\s+cleared\\.sourcePostUrl = true;` + `function ${apply}\\([\\s\\S]{0,2800}?\\} else if \\(sourceTagged\\) \\{[\\s\\S]{0,600}?sourcePostUrl = '';\\s+sourceTagged = false;\\s+cleared\\.sourcePostUrl = true;` ) ); expect(source).toMatch( new RegExp( - `function ${apply}\\([\\s\\S]{0,2400}?\\} else if \\(dateTagged\\) \\{\\s+commissionedAt = '';\\s+dateTagged = false;\\s+cleared\\.commissionedAt = true;` + `function ${apply}\\([\\s\\S]{0,3200}?\\} else if \\(dateTagged\\) \\{\\s+commissionedAt = '';\\s+dateTagged = false;\\s+cleared\\.commissionedAt = true;` ) ); expect(source).toMatch(/cleared=\{(shared|lookup)Cleared\}/); } + // A no-match is a result too: it fills neither field, which empties what + // the last lookup filled. Returning early on it left the first lookup's + // URL and date on the form, still tagged, under "no match". + for (const [source, apply] of [ + [UPLOAD, 'applyShared'], + [EDIT, 'applyPrefill'] + ] as const) { + expect(source).toMatch( + new RegExp( + `function ${apply}\\([\\s\\S]{0,1200}?if \\(next\\.kind !== 'results' && next\\.kind !== 'no_match'\\) return` + ) + ); + // And a no-match prefills nothing, so every fill branch is skipped and + // every clearing branch runs. + expect(source).toMatch( + new RegExp( + `const fields: LookupFields =\\s+next\\.kind === 'results'\\s+\\? prefillForResult\\([\\s\\S]{0,200}?\\)\\s+: \\{\\};` + ) + ); + } + // The no-match arm has no status line of its own, so the sentence is + // rendered there as well as under a result. + expect(PANEL).toMatch( + /\{:else if lookup\.kind === 'no_match'\}[\s\S]{0,600}?\{#if emptiedOnly\}\s+

\{emptiedText\}<\/p>/ + ); // The panel reads that record, and says the field was emptied rather than // left as it was. expect(PANEL).toMatch(/statusLineKind\(filled, \{ clash: !!clash, edited, urlHeld: sourceUrlHeld, cleared \}\)/); @@ -1203,7 +1257,8 @@ describe('focus after the panel goes away', () => { 'admin_lookup_status_date_and_url_emptied', 'admin_lookup_status_both_emptied', 'admin_lookup_status_url_emptied', - 'admin_lookup_status_date_emptied' + 'admin_lookup_status_date_emptied', + 'admin_lookup_status_clash_emptied' ]) { expect(PANEL).toContain(`m.${key}`); } @@ -1258,7 +1313,7 @@ describe('the rating tag beside NSFW', () => { // The pill is a SIBLING of the label, not inside it (SONA-220). Any // comment between the two is prose, not part of the contract. expect(EDIT).toMatch( - /

[\s\S]*?<\/label>[\s\S]*?/ + /
[\s\S]*?<\/label>[\s\S]*?/ ); // entail.dev's rating shares the row since SONA-220, so the checkbox points // at a derived that joins whichever of the two pills is on screen. @@ -1279,8 +1334,12 @@ describe('the rating tag beside NSFW', () => { }); it('lets the row wrap a pill to its own line', () => { + // One rule for the row, in app.css. A page-scoped copy outranks the global + // one on specificity, so the copies quietly held the wide gap and the + // centred alignment that the two container queries are trying to change. + expect(APP_CSS).toMatch(/\.tag-check-row \{[^}]*flex-wrap: wrap;/); for (const source of [UPLOAD, EDIT]) { - expect(source).toMatch(/\.nsfw-row[\s\S]{0,80}\{[^}]*flex-wrap: wrap;/); + expect(source).not.toMatch(/\.nsfw-row/); } // Wrapped, the two pills and the "Mark it NSFW" button all line up under // the label text rather than one of them under the checkbox. Asked of the @@ -1288,25 +1347,40 @@ describe('the rating tag beside NSFW', () => { // window, so a viewport query indented the phone and left every width in // between wrapping to the checkbox's edge. expect(APP_CSS).toMatch( - /@container admin-form \(max-width: 559px\) \{[\s\S]{0,900}?\.tag-check-row \.tag-rating-note,\s+\.tag-check-row \.rating-tag,\s+\.tag-check-row \.btn \{\s+margin-left: 24px;/ + /@container admin-form not \(min-width: 560px\) \{[\s\S]{0,900}?\.tag-check-row \.tag-rating-note,\s+\.tag-check-row \.rating-tag,\s+\.tag-check-row \.btn \{\s+margin-left: 24px;/ ); expect(APP_CSS).not.toMatch(/@media \(max-width: 640px\)[\s\S]{0,900}?\.tag-check-row/); + // The two ranges abut: `max-width: 559px` and `min-width: 560px` leave a + // fractional column width — 559.5px is what a fractional grid track gives + // — matching neither, with the row indented by neither rule. + expect(APP_CSS).not.toMatch(/@container admin-form \(max-width/); + // Stacked rather than wrapped in that range, which is what the mocks draw: + // wrapping put the 24px indent on items that had not wrapped, so an inline + // row carried 32px gaps at 1024 and 1150 on the edit form. + expect(APP_CSS).toMatch( + /@container admin-form not \(min-width: 560px\) \{[\s\S]{0,900}?\.tag-check-row \{\s+flex-direction: column;\s+align-items: flex-start;/ + ); }); // The text grows with the number of sites; nowrap made the pill wider than // its column and gave the whole document a horizontal scrollbar at 320px // (1.4.10), wider than its tile in the grid, and — kept on one line beside a // second pill and a button — it pushed that button onto a row of its own. + // + // One rule for all three pills, in app.css: the two forms and the tiles hold + // the same pill, and the copy in each page was byte-for-byte the other's. it('lets the pill wrap rather than pushing the page sideways', () => { + expect(APP_CSS).toMatch(/\.rating-tag \{[^}]*max-width: 100%/); + expect(APP_CSS).toMatch(/\.rating-tag \{[^}]*white-space: normal;/); + // break-word, not anywhere: anywhere breaks mid-word, which is how a + // squeezed pill ends up one character per line. + expect(APP_CSS).toMatch(/\.rating-tag \{[^}]*overflow-wrap: break-word;/); + expect(APP_CSS).not.toMatch(/\.rating-tag \{[^}]*overflow-wrap: anywhere;/); + // No rule anywhere puts it back on one line, and neither page carries a + // copy of the rule any more. + expect(APP_CSS).not.toMatch(/\.rating-tag \{[^}]*white-space: nowrap;/); for (const source of [UPLOAD, EDIT]) { - expect(source).toMatch(/\.rating-tag \{[^}]*max-width: 100%/); - expect(source).toMatch(/\.rating-tag \{[^}]*white-space: normal;/); - // break-word, not anywhere: anywhere breaks mid-word, which is how a - // squeezed pill ends up one character per line. - expect(source).toMatch(/\.rating-tag \{[^}]*overflow-wrap: break-word;/); - expect(source).not.toMatch(/\.rating-tag \{[^}]*overflow-wrap: anywhere;/); - // No rule anywhere puts it back on one line. - expect(source).not.toMatch(/\.rating-tag \{[^}]*white-space: nowrap;/); + expect(source).not.toMatch(/\.rating-tag \{/); } expect(UPLOAD).toMatch(/\.tile-nsfw-row \{[^}]*min-width: 0/); }); @@ -1316,10 +1390,8 @@ describe('the rating tag beside NSFW', () => { // last lines run flush against the border. A fixed radius holds through any // number of lines, and at one line the two are the same shape. it('caps the pill radius rather than following the box height', () => { - for (const source of [UPLOAD, EDIT]) { - expect(source).toMatch(/\.rating-tag \{[^}]*border-radius: 12px;/); - expect(source).not.toMatch(/\.rating-tag \{[^}]*border-radius: var\(--radius-pill\)/); - } + expect(APP_CSS).toMatch(/\.rating-tag \{[^}]*border-radius: 12px;/); + expect(APP_CSS).not.toMatch(/\.rating-tag \{[^}]*border-radius: var\(--radius-pill\)/); }); // Where the column has room for all four, the two rating items give up the @@ -1329,13 +1401,25 @@ describe('the rating tag beside NSFW', () => { // keyed to the window instead, the same rule ran in a 219px column and shrank // both items to about one character per line. it('shrinks the two rating items only where the column has room', () => { + expect(APP_CSS).toMatch( + /@container admin-form \(min-width: 560px\) \{\s+\.tag-check-row \.rating-tag,\s+\.tag-check-row \.tag-rating-note \{\s+flex: 1 1 0;\s+min-width: 14ch;\s+max-width: max-content;/ + ); + // Three gaps at 8px against an 8px shortfall: the edit form's row wants + // 608px in a 600px column, so the note wrapped "entail.dev." to a second + // line. 4px buys back the 12px that keeps all four items on one line. + expect(APP_CSS).toMatch( + /@container admin-form \(min-width: 560px\) \{[\s\S]{0,600}?\.tag-check-row \{\s+gap: 4px;/ + ); + // Nothing shrinks off a viewport query any more, and the rule is keyed to + // the row rather than to either page's copy of it. + expect(APP_CSS).not.toMatch(/@media \([^)]*\) \{\s+\.tag-check-row \.rating-tag,/); for (const source of [UPLOAD, EDIT]) { - expect(source).toMatch( - /@container admin-form \(min-width: 560px\) \{\s+\.nsfw-row \.rating-tag,\s+\.nsfw-row :global\(\.tag-rating-note\) \{\s+flex: 1 1 0;\s+min-width: 14ch;\s+max-width: max-content;/ - ); - // Nothing shrinks off a viewport query any more. - expect(source).not.toMatch(/@media \([^)]*\) \{\s+\.nsfw-row \.rating-tag,/); + expect(source).not.toMatch(/@container admin-form \(min-width: 560px\)/); } + // The tile row is not a tag-check-row, so the tile pill keeps its own + // width — the rule above would shrink it inside a 170px tile. + expect(UPLOAD).toMatch(/
/); + expect(UPLOAD).not.toMatch(/class="tile-nsfw-row tag-check-row"/); // The column each form names, and the tile row is not one of them. expect(EDIT).toMatch( /\.edit-form \{[\s\S]{0,400}?container-type: inline-size;\s+container-name: admin-form;/ diff --git a/src/lib/artist-lookup.test.ts b/src/lib/artist-lookup.test.ts index 07f3fca0..152b9dac 100644 --- a/src/lib/artist-lookup.test.ts +++ b/src/lib/artist-lookup.test.ts @@ -788,15 +788,46 @@ describe('statusLineKind', () => { { clash: true, cleared: { sourcePostUrl: true, commissionedAt: true } } ) ).toBe('both_emptied'); + // Only the URL emptied, under a body saying Sona found a post and declined + // it. The plain url_emptied sentence gives "this lookup filled nothing in + // its place" as the reason, which contradicts that body, so the clash gets + // a sentence naming its own reason. + expect(statusLineKind({}, { clash: true, cleared: { sourcePostUrl: true } })).toBe( + 'clash_emptied' + ); + expect( + m.admin_lookup_status_clash_emptied({ site: 'FurAffinity', title: 'Ref' }, { locale: 'en' }) + ).toBe( + 'Sona cleared the source post URL the last lookup filled, because the FurAffinity post Sona found is already the source of Ref. You can type one in before you save.' + ); + for (const locale of ['en', 'ja'] as const) { + expect( + m.admin_lookup_status_clash_emptied({ site: 'FurAffinity', title: 'Ref' }, { locale }) + ).toContain('Ref'); + } }); it('names the emptied field in the sentence, in both locales', () => { expect(m.admin_lookup_status_url_and_date_emptied({ site: 'Twitter' }, { locale: 'en' })).toBe( - 'Sona filled the source post URL from the Twitter post and emptied the commissioned date the last lookup filled, because that post carries no date. You can change the URL before you save.' + 'Sona filled the source post URL from the Twitter post and cleared the commissioned date the last lookup filled, because that post has no date. You can change the URL before you save.' ); expect(m.admin_lookup_status_url_emptied({}, { locale: 'en' })).toBe( - 'Sona emptied the source post URL the last lookup filled, because this result has no post to put there.' + 'Sona cleared the source post URL the last lookup filled, because this lookup filled nothing in its place. You can type one in before you save.' + ); + // A clash result DOES have a post — Sona declined it — so no sentence on + // this path may say the lookup found nothing to put there. + expect(m.admin_lookup_status_date_and_url_emptied({ site: 'Twitter' }, { locale: 'en' })).toBe( + 'Sona filled the commissioned date from the Twitter post and cleared the source post URL the last lookup filled, because this result has no link to put there. You can change the date before you save.' + ); + expect(m.admin_lookup_status_both_emptied({}, { locale: 'en' })).toBe( + 'Sona cleared the source post URL and commissioned date the last lookup filled, because this lookup filled neither one.' + ); + expect(m.admin_lookup_status_date_emptied({}, { locale: 'en' })).toBe( + 'Sona cleared the commissioned date the last lookup filled, because this result has no date.' ); + // The panel's click-time announcement and these lines describe the same + // thing, so they use the same verb. + expect(m.admin_lookup_announce_searching_cleared({}, { locale: 'en' })).toContain('cleared'); for (const line of [ m.admin_lookup_status_url_and_date_emptied({ site: 'Twitter' }, { locale: 'ja' }), m.admin_lookup_status_date_and_url_emptied({ site: 'Twitter' }, { locale: 'ja' }), @@ -804,7 +835,9 @@ describe('statusLineKind', () => { m.admin_lookup_status_url_emptied({}, { locale: 'ja' }), m.admin_lookup_status_date_emptied({}, { locale: 'ja' }) ]) { - expect(line).toContain('空にしました'); + expect(line).toContain('消去しました'); + // 検索 is what every other Japanese string in the file calls a lookup. + expect(line).not.toContain('ルックアップ'); } // None of them claims a field was left alone, which is what they replace. for (const locale of ['en', 'ja'] as const) { diff --git a/src/lib/artist-lookup.ts b/src/lib/artist-lookup.ts index dc00aafb..5bfc1dcd 100644 --- a/src/lib/artist-lookup.ts +++ b/src/lib/artist-lookup.ts @@ -485,6 +485,7 @@ export type StatusLineKind = | 'date_kept' | 'clash' | 'clash_kept' + | 'clash_emptied' | 'url_and_date_emptied' | 'date_and_url_emptied' | 'both_emptied' @@ -531,6 +532,13 @@ export function statusLineKind( // The clash sentence already says the URL was left empty, which is what // emptying it leaves behind, so only the date needs the extra kinds below. if (date) return options.urlHeld ? 'clash_kept' : 'clash'; + // A clash with no date to report either. The plain url_emptied sentence + // gives "this lookup filled nothing in its place" as the reason, which + // reads as a lookup that found nothing under a body saying Sona found a + // post and declined it, so the clash names its own reason. Both fields + // emptied keeps both_emptied below: that one claims no reason the clash + // body contradicts. + if (cleared.sourcePostUrl && !cleared.commissionedAt) return 'clash_emptied'; } else { if (url && date) return 'both'; if (url) { diff --git a/src/lib/components/ArtistLookupPanel.svelte b/src/lib/components/ArtistLookupPanel.svelte index e024964f..dceba91b 100644 --- a/src/lib/components/ArtistLookupPanel.svelte +++ b/src/lib/components/ArtistLookupPanel.svelte @@ -127,9 +127,20 @@ ); // The sentences that only report an emptied field name no site, and the // result that empties one can be a no-match with no prefill match to name. + // Held as text rather than inline in the markup because a no-match renders + // its own arm, well above the status line every other result uses. const emptiedOnly = $derived( statusKind === 'both_emptied' || statusKind === 'url_emptied' || statusKind === 'date_emptied' ); + const emptiedText = $derived( + statusKind === 'both_emptied' + ? m.admin_lookup_status_both_emptied() + : statusKind === 'url_emptied' + ? m.admin_lookup_status_url_emptied() + : statusKind === 'date_emptied' + ? m.admin_lookup_status_date_emptied() + : '' + ); const seedKind = $derived(seedStatusKind(seeded, seedEdited)); // The "Sets the artist to {name}." sentence and the button it // names render on the same condition, so the button can describe itself with @@ -213,6 +224,13 @@ {:else if lookup.kind === 'no_match'}
{m.admin_lookup_no_match_eyebrow()}

{m.admin_lookup_no_match_body()}

+ + {#if emptiedOnly} +

{emptiedText}

+ {/if}

{m.admin_lookup_no_match_hint()}

{:else if lookup.kind === 'failed'} {#if lookup.reason === 'rate_limited'} @@ -382,12 +400,8 @@ {#if statusKind !== 'none' && (prefill || emptiedOnly)}

- {#if statusKind === 'both_emptied'} - {m.admin_lookup_status_both_emptied()} - {:else if statusKind === 'url_emptied'} - {m.admin_lookup_status_url_emptied()} - {:else if statusKind === 'date_emptied'} - {m.admin_lookup_status_date_emptied()} + {#if emptiedOnly} + {emptiedText} {:else if !prefill} -

+