From cb737b7ae340ca6c474219cf36d76fff3f4e6784 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Fri, 16 Oct 2026 16:31:17 +0200 Subject: [PATCH] fix: SEO SPA editor last fixes (3/4) --- features/editor_seo.feature | 30 ++-- features/editor_seo.qa.md | 2 +- web/src/components/MediaField.vue | 2 +- web/src/components/SeoPanel.vue | 12 +- web/src/i18n/messages.ts | 26 ++- web/src/i18n/seoMessages.spec.ts | 8 +- web/src/lib/__tests__/directory.spec.ts | 17 ++ web/src/lib/__tests__/seoCopy.spec.ts | 13 +- web/src/lib/articleSeo.ts | 2 +- web/src/lib/directory.ts | 84 ++++++++-- web/src/lib/seoApiPreview.ts | 38 ++--- web/src/lib/seoCopy.ts | 21 ++- web/src/lib/seoPreview.ts | 1 - .../views/__tests__/EditorSeoCheck.spec.ts | 4 +- .../views/__tests__/EditorSeoDialog.spec.ts | 14 +- .../views/__tests__/EditorSeoPanel.spec.ts | 2 + web/src/views/__tests__/EditorSeoSave.spec.ts | 18 +++ .../__tests__/EditorSeoSuggestions.spec.ts | 19 ++- web/visual/states.ts | 150 ++++++++++++------ 19 files changed, 316 insertions(+), 147 deletions(-) diff --git a/features/editor_seo.feature b/features/editor_seo.feature index 823ae6b..3960549 100644 --- a/features/editor_seo.feature +++ b/features/editor_seo.feature @@ -14,7 +14,7 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets" And the SPA never re-implements a suggestion rule. It renders the suggestions, the order and the status the check returns. The copy of each code lives in the SPA and has a French entry And the suggestion codes are title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt and short And a suggestion's field maps to a link label: seo_title "meta title", seo_description "meta description", cover_image_alt and cover_image_id "cover image", slug "URL slug", author_ids "authors", body "body" - And the soft ranges of the counters are meta title 30 to 60 and description 70 to 160 (docs/design). They colour a counter and never block + And the soft ranges of the counters are meta title 30 to 60 and description 70 to 160 (docs/design), and the social title has no counter. They colour a counter and never block And suggestions never disable Save, "Save and publish" or Publish And the sample article is 12, "How we price a bathroom renovation", online on the site Renew (renew.example, id 1), slug "how-we-price-a-bathroom-renovation", with no SEO field set, and the author "Inès Duarte" And the sample cover media is "north-wall.jpg" (the site social image) and the sample panel values are those of the board frames "Panel: filled in" and "Panel: outside the soft ranges" @@ -103,20 +103,19 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets" | body | body | focus moves to the article body in the editor | | author_ids | authors | the "Site and authors" section opens and focus moves to its list | - @not-in-design - Scenario Outline: Copy of the codes the design does not draw + Scenario Outline: Copy of the other codes, as the board draws it Given the check answers the code "" with params Then the message reads "" and has a French entry Examples: - | code | params | message | - | title_empty | {} | The meta title is empty. | - | desc_long | {"length": 170, "max": 160} | The description is 170 characters. About 70 to 160 reads best in results. | - | cover_none | {} | This article has no cover image. | - | slug_long | {"length": 80, "max": 75} | The URL slug is 80 characters. Shorter addresses are easier to share. | - | no_au | {} | This article has no author. | - | body_alt | {"count": 2} | 2 images in the body have no alt text. | - | short | {"length": 40, "min": 300} | The body is 40 characters. Longer articles tend to do better in search. | + | code | params | message | + | title_empty | {} | No meta title yet. The article title is used instead. | + | desc_long | {"length": 170, "max": 160} | The description is 170 characters. Search engines may cut it off after about 160. | + | cover_none | {} | No cover image. The site's social image is used. | + | slug_long | {"length": 80, "max": 75} | The slug is 80 characters. Shorter addresses are easier to share. | + | no_au | {} | This article has no authors, so the structured data has no author. | + | body_alt | {"count": 2} | 2 images in the body have no alt text. | + | short | {"length": 40, "min": 300} | The article is under 300 words. That is fine for a short note. | Scenario Outline: The messages the design does draw use the params of the response Given the check answers the code "" with params @@ -316,6 +315,7 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets" | renew.example/costs | the field shows "Enter a full address starting with https://" and the field is invalid | | http://renew.example/a | the field shows "Enter a full address starting with https://" | | https://renew.example/a | no error is shown | + | " https://renew.example/a" | the field shows "Enter a full address starting with https://" (the value is never trimmed) | | | no error is shown and "Canonical URL: automatic" is shown when collapsed | Scenario: Canonical refused by the API @@ -380,7 +380,7 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets" Scenario: Saving state Given "Save changes" was activated - Then while the request runs every field of the panel is disabled at 40%, the status reads "Saving…" and the page behind stays as it was + Then while the request runs every field of the panel is disabled with its text colour unchanged, the status reads "Saving…" and the page behind stays as it was Scenario: Saved When the PATCH answers 200 @@ -408,6 +408,12 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets" When the user activates Save, presses Ctrl or Cmd S, or activates "Save and publish" Then the PATCH holds seo_title and the publish request follows for "Save and publish" + Scenario: Save and publish saves a dirty draft first + Given the draft is dirty + When the user activates "Save and publish" + Then the PATCH is sent first and the publish request follows only if it succeeded + And with a clean draft only the publish request is sent + Scenario: Leaving with unsaved SEO edits Given the draft holds an unsaved SEO value When the user navigates away diff --git a/features/editor_seo.qa.md b/features/editor_seo.qa.md index c7af159..aed505a 100644 --- a/features/editor_seo.qa.md +++ b/features/editor_seo.qa.md @@ -50,7 +50,7 @@ Setups go in `web/visual/states.ts`, one setup per state, with mocked /v1 and th Each has a French entry in `web/src/i18n/messages.ts`. -1. The copy of the codes the design does not draw: title_empty, desc_long, cover_none, slug_long, no_au, body_alt, short. +1. The codes now use the board copy: title_empty, desc_long, cover_none, slug_long, no_au, body_alt, short. 2. The other fallback chips (excerpt, site default description, no image) and the chips of the dialog. 3. The check failure text "Couldn't check this article." with "Try again" (no state in the design). 4. The "Go to authors" and "Go to URL slug" labels, and where "Go to body" and "Go to authors" move focus. diff --git a/web/src/components/MediaField.vue b/web/src/components/MediaField.vue index 56dfe7b..f136f37 100644 --- a/web/src/components/MediaField.vue +++ b/web/src/components/MediaField.vue @@ -76,7 +76,7 @@ function remove() {
{{ caption }} diff --git a/web/src/components/SeoPanel.vue b/web/src/components/SeoPanel.vue index 2ef4ceb..ddad893 100644 --- a/web/src/components/SeoPanel.vue +++ b/web/src/components/SeoPanel.vue @@ -30,7 +30,6 @@ import { chipLabel, descriptionChip } from '@/lib/seoCopy' import type { DirectoryNotice } from '@/lib/directoryTypes' import { DESCRIPTION_RANGE, - SOCIAL_TITLE_RANGE, TITLE_RANGE, hasSocialOverrides, siteDisplayName, @@ -328,7 +327,7 @@ onMounted(() => { names.resolve([siteImageId.value, seo.value.cover_image_id, seo.value.social_image_id]) }) -const DISABLED_FIELD = 'disabled:opacity-40' +const DISABLED_FIELD = 'disabled:bg-elevated disabled:opacity-100' const fieldUi = { base: `h-11 text-lg text-ellipsis md:h-10 md:text-sm ${DISABLED_FIELD}` } const monoFieldUi = { base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${DISABLED_FIELD}`, @@ -553,13 +552,6 @@ const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD >
- { const rows = apiPreviewRows(resolved) expect(rows.map((row) => row.chip)).toEqual([ - 'Titre de l’article utilisé', - 'Extrait utilisé', - 'Adresse du site et slug utilisés', + 'titre de l’article + modèle', + 'extrait', + 'adresse du site + slug', null, null, - 'Image de partage du site utilisée', + 'image de partage du site', null, null, null, diff --git a/web/src/lib/__tests__/directory.spec.ts b/web/src/lib/__tests__/directory.spec.ts index 9300e15..7e42474 100644 --- a/web/src/lib/__tests__/directory.spec.ts +++ b/web/src/lib/__tests__/directory.spec.ts @@ -167,3 +167,20 @@ describe('parseDirectoryQuery', () => { }) }) }) + +describe('slugify parity with the API slug generator', () => { + it.each([ + ['Crème Brûlée à la façon', 'creme-brulee-a-la-facon'], + ['Straße œuvre Łódź', 'strasse-oeuvre-lodz'], + ['Ångström', 'angstrom'], + ['Tiếng Việt', 'ti-ng-vi-t'], + [' --Hello, World!-- ', 'hello-world'], + ['', ''], + ])('turns %j into %j', (text, expected) => { + expect(slugify(text, 100)).toBe(expected) + }) + + it('cuts to the limit and drops a trailing dash', () => { + expect(slugify('abcd efgh', 5)).toBe('abcd') + }) +}) diff --git a/web/src/lib/__tests__/seoCopy.spec.ts b/web/src/lib/__tests__/seoCopy.spec.ts index 80a2c44..b8cc69f 100644 --- a/web/src/lib/__tests__/seoCopy.spec.ts +++ b/web/src/lib/__tests__/seoCopy.spec.ts @@ -8,13 +8,16 @@ function suggestion(code: string, params: Record = {}): SeoSugge } describe('suggestionMessage', () => { - it('uses the same wording for short and long descriptions with the length filled in', () => { + it('fills the length and the maximum into the short and long description messages', () => { const short = suggestionMessage(suggestion('desc_short', { length: 12 })) - const long = suggestionMessage(suggestion('desc_long', { length: 200 })) + const long = suggestionMessage(suggestion('desc_long', { length: 200, max: 160 })) - expect(short).toContain('12') - expect(long).toContain('200') - expect(short.replace('12', '#')).toBe(long.replace('200', '#')) + expect(short).toBe( + 'The description is 12 characters. About 70 to 160 reads best in results.', + ) + expect(long).toBe( + 'The description is 200 characters. Search engines may cut it off after about 160.', + ) }) it('switches between the singular and plural body image wording', () => { diff --git a/web/src/lib/articleSeo.ts b/web/src/lib/articleSeo.ts index bb5fe07..5ce58e3 100644 --- a/web/src/lib/articleSeo.ts +++ b/web/src/lib/articleSeo.ts @@ -114,5 +114,5 @@ export function isAbsoluteHttpsUrl(value: string) { } export function canonicalIsValid(value: string) { - return value.trim() === '' || isAbsoluteHttpsUrl(value.trim()) + return value === '' || (value === value.trim() && isAbsoluteHttpsUrl(value)) } diff --git a/web/src/lib/directory.ts b/web/src/lib/directory.ts index b9b45c5..aa68cc9 100644 --- a/web/src/lib/directory.ts +++ b/web/src/lib/directory.ts @@ -6,28 +6,88 @@ export const SITE_KEY_MAX = 32 export const SITE_KEY_MIN = 2 export const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'] -const LIGATURES: Record = { +const FOLDED_LETTERS: Record = { + à: 'a', + á: 'a', + â: 'a', + ã: 'a', + ä: 'a', + å: 'a', + ā: 'a', + ă: 'a', + ą: 'a', + ç: 'c', + ć: 'c', + č: 'c', + ĉ: 'c', + ď: 'd', + è: 'e', + é: 'e', + ê: 'e', + ë: 'e', + ē: 'e', + ė: 'e', + ę: 'e', + ě: 'e', + ğ: 'g', + ì: 'i', + í: 'i', + î: 'i', + ï: 'i', + ī: 'i', + į: 'i', + ı: 'i', + ł: 'l', + ñ: 'n', + ń: 'n', + ň: 'n', + ò: 'o', + ó: 'o', + ô: 'o', + õ: 'o', + ö: 'o', + ø: 'o', + ō: 'o', + ő: 'o', + ř: 'r', + ś: 's', + š: 's', + ş: 's', + ș: 's', + ť: 't', + ț: 't', + ţ: 't', + ù: 'u', + ú: 'u', + û: 'u', + ü: 'u', + ū: 'u', + ů: 'u', + ű: 'u', + ų: 'u', + ý: 'y', + ÿ: 'y', + ź: 'z', + ż: 'z', + ž: 'z', æ: 'ae', + đ: 'd', œ: 'oe', ß: 'ss', - ø: 'o', - ł: 'l', - đ: 'd', - ı: 'i', + ľ: 'l', } -const COMBINING_MARKS = /\p{M}/gu const NON_SLUG_RUN = /[^a-z0-9]+/g const EDGE_DASHES = /^-+|-+$/g +const TRAILING_DASHES = /-+$/ const TITLE_PREFIX = /^Dr\.\s*/ const NUMBERED_SUFFIX = /-(\d+)$/ export function slugify(text: string, max: number) { - const folded = text - .toLowerCase() - .normalize('NFD') - .replace(COMBINING_MARKS, '') - .replace(/[æœßøłđı]/g, (letter) => LIGATURES[letter] ?? letter) - return folded.replace(NON_SLUG_RUN, '-').slice(0, max).replace(EDGE_DASHES, '') + const folded = [...text.toLowerCase()] + .map((letter) => FOLDED_LETTERS[letter] ?? letter) + .join('') + const generated = folded.replace(NON_SLUG_RUN, '-').replace(EDGE_DASHES, '') + return generated.slice(0, max).replace(TRAILING_DASHES, '') } export function suggestFreeSlug( diff --git a/web/src/lib/seoApiPreview.ts b/web/src/lib/seoApiPreview.ts index cc03749..60c390e 100644 --- a/web/src/lib/seoApiPreview.ts +++ b/web/src/lib/seoApiPreview.ts @@ -1,5 +1,4 @@ import { t, locale } from '@/i18n' -import { chipLabel, descriptionChip, type ChipKey } from '@/lib/seoCopy' import { formatAbsoluteDateFromIso } from '@/lib/relativeDate' import type { ResolvedSeo } from '@/lib/seoTypes' @@ -40,22 +39,23 @@ export function formatApiDate(iso: string | null) { : `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${clock}` } -function titleChip(source: string) { - return source === 'title' ? chipLabel('article_title') : null +const FALLBACK_CHIPS: Record string>> = { + title: { title: () => t('article title + template') }, + description: { + body: () => t('first sentence of the body'), + excerpt: () => t('excerpt'), + site: () => t('site default description'), + }, + image: { + cover: () => t('cover image'), + site: () => t('site social image'), + none: () => t('No image'), + }, + canonical: { site: () => t('site address + slug') }, } -function imageChip(source: string) { - const keys: Record = { - cover: 'cover_image', - site: 'site_image', - none: 'no_image', - } - const key = keys[source] - return key ? chipLabel(key) : null -} - -function canonicalChip(source: string) { - return source === 'site' ? chipLabel('canonical') : null +function fallbackChip(field: string, source: string) { + return FALLBACK_CHIPS[field]?.[source]?.() ?? null } function authorLine(author: ResolvedSeo['authors'][number]) { @@ -77,20 +77,20 @@ function jsonLdSummary(jsonLd: Record) { export function apiPreviewRows(resolved: ResolvedSeo): ApiPreviewRow[] { const { fallbacks } = resolved return [ - { key: 'title', value: resolved.title, chip: titleChip(fallbacks.title) }, + { key: 'title', value: resolved.title, chip: fallbackChip('title', fallbacks.title) }, { key: 'description', value: resolved.description, - chip: descriptionChip(fallbacks.description), + chip: fallbackChip('description', fallbacks.description), }, { key: 'canonical_url', value: resolved.canonical_url, - chip: canonicalChip(fallbacks.canonical), + chip: fallbackChip('canonical', fallbacks.canonical), }, { key: 'robots', value: resolved.robots, chip: null }, { key: 'og.type', value: resolved.og.type, chip: null }, - { key: 'og.image', value: resolved.og.image, chip: imageChip(fallbacks.image) }, + { key: 'og.image', value: resolved.og.image, chip: fallbackChip('image', fallbacks.image) }, { key: 'og.image_alt', value: resolved.og.image_alt, chip: null }, { key: 'twitter.card', value: resolved.twitter.card, chip: null }, { diff --git a/web/src/lib/seoCopy.ts b/web/src/lib/seoCopy.ts index 90f4274..f86e6b6 100644 --- a/web/src/lib/seoCopy.ts +++ b/web/src/lib/seoCopy.ts @@ -14,25 +14,24 @@ const MESSAGES: Record) => strin params.length, params.max, ]), - title_empty: () => t('The meta title is empty.'), + title_empty: () => t('No meta title yet. The article title is used instead.'), desc_short: descriptionLength, - desc_long: descriptionLength, - cover_alt: () => t('The cover image has no alt text. Add a short description of it.'), - cover_none: () => t('This article has no cover image.'), - slug_long: (params) => - t('The URL slug is {0} characters. Shorter addresses are easier to share.', [ + desc_long: (params) => + t('The description is {0} characters. Search engines may cut it off after about {1}.', [ params.length, + params.max, ]), - no_au: () => t('This article has no author.'), + cover_alt: () => t('The cover image has no alt text. Add a short description of it.'), + cover_none: () => t("No cover image. The site's social image is used."), + slug_long: (params) => + t('The slug is {0} characters. Shorter addresses are easier to share.', [params.length]), + no_au: () => t('This article has no authors, so the structured data has no author.'), no_h: () => t('The body has no headings. H2 headings help readers scan the article.'), body_alt: (params) => params.count === 1 ? t('1 image in the body has no alt text.') : t('{0} images in the body have no alt text.', [params.count]), - short: (params) => - t('The body is {0} characters. Longer articles tend to do better in search.', [ - params.length, - ]), + short: () => t('The article is under 300 words. That is fine for a short note.'), } const LINK_LABELS: Record string> = { diff --git a/web/src/lib/seoPreview.ts b/web/src/lib/seoPreview.ts index e4e2f6d..84cddd4 100644 --- a/web/src/lib/seoPreview.ts +++ b/web/src/lib/seoPreview.ts @@ -11,7 +11,6 @@ export const PREVIEW_LIMITS: Record { gates[0]!.resolve(checkAnswer([], 'hidden')) await flushPromises() - expect(textOf(sectionBody())).toContain('This article has no cover image.') + expect(textOf(sectionBody())).toContain("No cover image. The site's social image is used.") expect(textOf(sectionBody())).not.toContain('Hidden from search engines') - expect(textOf(panelRoot()!)).toContain('This article has no cover image.') + expect(textOf(panelRoot()!)).toContain("No cover image. The site's social image is used.") }) }) diff --git a/web/src/views/__tests__/EditorSeoDialog.spec.ts b/web/src/views/__tests__/EditorSeoDialog.spec.ts index 26b4147..71d54ce 100644 --- a/web/src/views/__tests__/EditorSeoDialog.spec.ts +++ b/web/src/views/__tests__/EditorSeoDialog.spec.ts @@ -222,13 +222,13 @@ describe('Formatted', () => { }) it.each([ - { key: 'title', source: 'title', chip: 'Using article title' }, - { key: 'description', source: 'body', chip: 'Using first sentence of the body' }, - { key: 'description', source: 'excerpt', chip: 'Using the excerpt' }, - { key: 'description', source: 'site', chip: 'Using the site default description' }, - { key: 'image', source: 'cover', chip: 'Using cover image' }, - { key: 'image', source: 'site', chip: 'Using site social image' }, - { key: 'canonical', source: 'site', chip: 'Using site address and slug' }, + { key: 'title', source: 'title', chip: 'article title + template' }, + { key: 'description', source: 'body', chip: 'first sentence of the body' }, + { key: 'description', source: 'excerpt', chip: 'excerpt' }, + { key: 'description', source: 'site', chip: 'site default description' }, + { key: 'image', source: 'cover', chip: 'cover image' }, + { key: 'image', source: 'site', chip: 'site social image' }, + { key: 'canonical', source: 'site', chip: 'site address + slug' }, { key: 'image', source: 'none', chip: 'No image' }, ])('shows "$chip" when fallbacks.$key is $source', async ({ key, source, chip }) => { await mountWithDialog({ diff --git a/web/src/views/__tests__/EditorSeoPanel.spec.ts b/web/src/views/__tests__/EditorSeoPanel.spec.ts index af2a408..6a5974d 100644 --- a/web/src/views/__tests__/EditorSeoPanel.spec.ts +++ b/web/src/views/__tests__/EditorSeoPanel.spec.ts @@ -427,6 +427,8 @@ describe('Advanced, canonical URL', () => { it.each([ { value: 'renew.example/costs', error: true }, { value: 'http://renew.example/a', error: true }, + { value: ' https://renew.example/a', error: true }, + { value: 'https://renew.example/a ', error: true }, { value: 'https://renew.example/a', error: false }, { value: '', error: false }, ])('for "$value" the error is shown: $error', async ({ value, error }) => { diff --git a/web/src/views/__tests__/EditorSeoSave.spec.ts b/web/src/views/__tests__/EditorSeoSave.spec.ts index 2ac9c19..c9d4900 100644 --- a/web/src/views/__tests__/EditorSeoSave.spec.ts +++ b/web/src/views/__tests__/EditorSeoSave.spec.ts @@ -149,6 +149,8 @@ describe('Saving state', () => { expect(fieldByLabel(panel, 'Meta description')!.disabled).toBe(true) expect(fieldByLabel(panel, 'Excerpt')!.disabled).toBe(true) expect(textOf(panel)).toContain('Saving…') + expect(fieldByLabel(panel, 'Meta title')!.className).toContain('disabled:opacity-100') + expect(fieldByLabel(panel, 'Meta title')!.className).not.toContain('opacity-40') expect( textOf(document.body.querySelector('[data-testid="save-button"]')!.parentElement), ).toContain('Save') @@ -286,6 +288,22 @@ describe('The editor Save button treats SEO edits like any other edit', () => { }) }) +describe('Publish with a clean draft', () => { + it('only sends the publish request', async () => { + const backend = await mountSeo(seoBackend()) + backend.responders['PATCH /v1/article/12/publish'] = () => ({ + ...backend.article, + is_online: true, + }) + const before = backend.calls.length + + await click('Publish') + + const patches = backend.calls.slice(before).filter((call) => call.method === 'PATCH') + expect(patches.map((call) => call.path)).toEqual(['/v1/article/12/publish']) + }) +}) + describe('Leaving with unsaved SEO edits', () => { it('asks Leave without saving? when the draft holds an unsaved SEO value', async () => { const { panel } = await failedPanelSave() diff --git a/web/src/views/__tests__/EditorSeoSuggestions.spec.ts b/web/src/views/__tests__/EditorSeoSuggestions.spec.ts index 600f7a4..d4ab22a 100644 --- a/web/src/views/__tests__/EditorSeoSuggestions.spec.ts +++ b/web/src/views/__tests__/EditorSeoSuggestions.spec.ts @@ -111,7 +111,7 @@ describe('The open section: address, suggestions and actions', () => { expect(panel).not.toBeNull() const suggestions = panel.querySelector('[data-testid="seo-panel-suggestions"]')! expect(document.activeElement).toBe(suggestions) - expect(textOf(suggestions)).toContain('The body is 40 characters.') + expect(textOf(suggestions)).toContain('The article is under 300 words.') }) it('shows no "+N more" action with three suggestions or fewer', async () => { @@ -263,28 +263,33 @@ describe('The messages follow the params of the response', () => { {}, 'The body has no headings. H2 headings help readers scan the article.', ], - ['title_empty', 'seo_title', {}, 'The meta title is empty.'], + ['title_empty', 'seo_title', {}, 'No meta title yet. The article title is used instead.'], [ 'desc_long', 'seo_description', { length: 170, max: 160 }, - 'The description is 170 characters. About 70 to 160 reads best in results.', + 'The description is 170 characters. Search engines may cut it off after about 160.', ], - ['cover_none', 'cover_image_id', {}, 'This article has no cover image.'], + ['cover_none', 'cover_image_id', {}, "No cover image. The site's social image is used."], [ 'slug_long', 'slug', { length: 80, max: 75 }, - 'The URL slug is 80 characters. Shorter addresses are easier to share.', + 'The slug is 80 characters. Shorter addresses are easier to share.', + ], + [ + 'no_au', + 'author_ids', + {}, + 'This article has no authors, so the structured data has no author.', ], - ['no_au', 'author_ids', {}, 'This article has no author.'], ['body_alt', 'body', { count: 2 }, '2 images in the body have no alt text.'], ['body_alt', 'body', { count: 1 }, '1 image in the body has no alt text.'], [ 'short', 'body', { length: 40, min: 300 }, - 'The body is 40 characters. Longer articles tend to do better in search.', + 'The article is under 300 words. That is fine for a short note.', ], ] as const)('renders %s with %j', async (code, field, params, message) => { const backend = seoBackend() diff --git a/web/visual/states.ts b/web/visual/states.ts index ba6a506..e011f8b 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -5694,6 +5694,22 @@ async function seoSaveFromPanel(page: Page) { .click() } +const dismissColumnReason = + 'The board draws a dismiss column and its footnote in the suggestion list; dismissal is deferred to slice 4' +const boardOmitsMobileDialogReason = + "The board's mobile frame omits the Create author dialog, so only the Details sheet is drawn" + +function withMobileException(state: T, mobile: number, reason: string): T { + return { + ...state, + exception: { + maxPercent: { desktop: 10, mobile }, + reason, + approvedBy: 'fsylvain', + }, + } +} + const editorSeoStates: Record = { 'Collapsed: ready': seoSidebarState({}, false), 'Collapsed: suggestions': seoSidebarState( @@ -5715,12 +5731,23 @@ const editorSeoStates: Record = { true, ), 'Open: other site': seoSidebarState({ article: { site_id: 2 }, sites: [seoJournal()] }, true), - 'Panel: defaults': seoPanelState({ check: seoCheck(['cover_none']) }), + 'Panel: defaults': withMobileException( + seoPanelState({ check: seoCheck(['cover_none']) }), + 26.5, + dismissColumnReason, + ), 'Panel: filled in': seoPanelState({ article: seoFilled }), - 'Panel: outside the soft ranges': seoPanelState({ - article: { seo_title: seoLongTitle, seo_description: 'What a bathroom renovation costs.' }, - check: seoCheck(['title_long', 'desc_short']), - }), + 'Panel: outside the soft ranges': withMobileException( + seoPanelState({ + article: { + seo_title: seoLongTitle, + seo_description: 'What a bathroom renovation costs.', + }, + check: seoCheck(['title_long', 'desc_short']), + }), + 15.5, + dismissColumnReason, + ), 'Panel: slug edited': seoPanelState({ article: { slug: 'bathroom-costs' } }), 'Panel: slug taken': seoPanelState( { @@ -5738,20 +5765,28 @@ const editorSeoStates: Record = { await page.getByText('is already used on Renew.').waitFor() }, ), - 'Panel: cover without alt': seoPanelState({ - article: seoCoverNoAlt, - check: seoCheck(['cover_alt']), - }), - 'Panel: choose cover': seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => { - await page - .getByRole('dialog') - .getByRole('button', { name: 'Choose image', exact: true }) - .first() - .click() - await waitForPickerTiles(page) - await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click() - await page.mouse.move(0, 0) - }), + 'Panel: cover without alt': withMobileException( + seoPanelState({ + article: seoCoverNoAlt, + check: seoCheck(['cover_alt']), + }), + 17, + dismissColumnReason, + ), + 'Panel: choose cover': withMobileException( + seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => { + await page + .getByRole('dialog') + .getByRole('button', { name: 'Choose image', exact: true }) + .first() + .click() + await waitForPickerTiles(page) + await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click() + await page.mouse.move(0, 0) + }), + 29, + dismissColumnReason, + ), 'Panel: social sharing open': seoPanelState( { article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } }, (page) => seoOpenDisclosure(page, 'Social sharing'), @@ -5778,12 +5813,19 @@ const editorSeoStates: Record = { async () => {}, 700, ), - 'Panel: mobile search preview': seoPanelState( - { article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) }, - async (page) => { - await page.getByRole('dialog').getByRole('tab', { name: 'Mobile', exact: true }).click() - await page.mouse.move(0, 0) - }, + 'Panel: mobile search preview': withMobileException( + seoPanelState( + { article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) }, + async (page) => { + await page + .getByRole('dialog') + .getByRole('tab', { name: 'Mobile', exact: true }) + .click() + await page.mouse.move(0, 0) + }, + ), + 17.5, + dismissColumnReason, ), 'Panel: checking': seoPanelState({ check: { status: 200, hang: true } }), 'Panel: saving': seoPanelState( @@ -6783,31 +6825,45 @@ export const visualStates: Record> = { { authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'] }, (page) => liftAuthor(page, 'Camille Roche'), ), - 'Create author: dialog': metaState({ withAuthor: true }, (page) => - openCreateAuthorDialog(page, 'Zoe Lambert'), + 'Create author: dialog': withMobileException( + metaState({ withAuthor: true }, (page) => openCreateAuthorDialog(page, 'Zoe Lambert')), + 55, + boardOmitsMobileDialogReason, ), - 'Create author: for me': metaState({}, async (page) => { - await openCreateAuthorDialog(page, 'Marta Alves') - await page.getByRole('checkbox', { name: /This is me/ }).click() - await page.mouse.move(0, 0) - }), - 'Create author: slug taken': metaState( - { withAuthor: true, overrides: { 'POST /v1/author/': newAuthorSlugTaken } }, - async (page) => { - await openCreateAuthorDialog(page, 'Zoe Lambert') - await page.getByRole('button', { name: 'Create author', exact: true }).click() - await page.getByText('is already used by another author').waitFor() + 'Create author: for me': withMobileException( + metaState({}, async (page) => { + await openCreateAuthorDialog(page, 'Marta Alves') + await page.getByRole('checkbox', { name: /This is me/ }).click() await page.mouse.move(0, 0) - }, + }), + 50, + boardOmitsMobileDialogReason, ), - 'Create author: failed': metaState( - { withAuthor: true, overrides: { 'POST /v1/author/': { status: 500 } } }, - async (page) => { - await openCreateAuthorDialog(page, 'Zoe Lambert') - await page.getByRole('button', { name: 'Create author', exact: true }).click() - await page.getByText("Couldn't create the author").waitFor() - await page.mouse.move(0, 0) - }, + 'Create author: slug taken': withMobileException( + metaState( + { withAuthor: true, overrides: { 'POST /v1/author/': newAuthorSlugTaken } }, + async (page) => { + await openCreateAuthorDialog(page, 'Zoe Lambert') + await page.getByRole('button', { name: 'Create author', exact: true }).click() + await page.getByText('is already used by another author').waitFor() + await page.mouse.move(0, 0) + }, + ), + 40, + boardOmitsMobileDialogReason, + ), + 'Create author: failed': withMobileException( + metaState( + { withAuthor: true, overrides: { 'POST /v1/author/': { status: 500 } } }, + async (page) => { + await openCreateAuthorDialog(page, 'Zoe Lambert') + await page.getByRole('button', { name: 'Create author', exact: true }).click() + await page.getByText("Couldn't create the author").waitFor() + await page.mouse.move(0, 0) + }, + ), + 52.5, + boardOmitsMobileDialogReason, ), Loading: { path: '/article/12/edit',