@jini-ai/admin 0.4.11 → 0.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.12 — 2026-10-16
4
+
5
+ - SEO: searchable entry picker and grouped per-entry form (6c128f9d); image fields name the file, styled analysis, slug for same-titled entries (bc1dbf0d); one Social preview section with an optional "Customize for X" (ff1a709f); X/Twitter labels, file names with extensions, muted inherited site-default image (4919371d); "Using image from page content" when the share image comes from the page (746457d9).
6
+ - Peers: `@jini-ai/ui` `^0.5.5` (grouped Select) and `@jini-ai/cms` `^0.5.13` (share-image source).
7
+
3
8
  ## 0.4.11 — 2026-10-14
4
9
 
5
10
  - `FieldAttributesDialog` and `SitemapModal` use the neutral `jini-domain-dialog` class instead of a host-named one; hosts style `.jini-domain-dialog` (47b9fa4f).
@@ -76,6 +76,10 @@ export interface AdminSeoOpenGraph {
76
76
  readonly image?: string;
77
77
  /** Source reference before resizing, used to diagnose the selected share image. */
78
78
  readonly imageRef?: string;
79
+ /** Effective image provenance; absent from older hosts. */
80
+ readonly imageSource?: 'override' | 'content' | 'default';
81
+ /** Present (true) only when the entry stores its own image override; absent from older hosts. */
82
+ readonly imageOverridden?: true;
79
83
  readonly siteName?: string;
80
84
  }
81
85
  export interface AdminSeoTwitterCard {
@@ -149,6 +153,18 @@ export interface AdminSeoEntryChoice {
149
153
  readonly id: string;
150
154
  readonly title: string;
151
155
  readonly status: string;
156
+ /** Tells two same-titled entries apart in the picker; optional for hosts that omit it. */
157
+ readonly slug?: string;
158
+ }
159
+ /** What an image field shows for a chosen library file: its name and, when stored, its size. */
160
+ export interface AdminSeoMediaSummary {
161
+ readonly id: string;
162
+ readonly slug?: string;
163
+ readonly title: string;
164
+ readonly width?: number | null;
165
+ readonly height?: number | null;
166
+ /** The stored bytes' sniffed type (`image/png`); gives the displayed name its extension. */
167
+ readonly contentType?: string | null;
152
168
  }
153
169
  export interface AdminSeoPort {
154
170
  getSeoEntry(requiredArgs: {
@@ -189,6 +205,10 @@ export interface AdminSeoPort {
189
205
  mediaPreviewUrl?(required: {
190
206
  value: string;
191
207
  }, optional?: Record<string, never>): string | null;
208
+ /** Optional: library rows so an image field can name its file instead of showing a reference. */
209
+ listSeoMedia?(required: Record<string, never>, optional?: {
210
+ signal?: AbortSignal;
211
+ }): Promise<readonly AdminSeoMediaSummary[]>;
192
212
  /** Original thumbnail URL; media-reference resolution stays on the server. */
193
213
  mediaOriginalUrl(requiredArgs: {
194
214
  id: string;
@@ -20,6 +20,11 @@ export function createHttpSeoApi({ transport, basePath }, { sitemapTransport, im
20
20
  analyzeSeoEntry: ({ entryId }) => data(`${entryPath(entryId)}/analyze`, 'GET'),
21
21
  listSeoPosts: () => choices('posts'),
22
22
  listSeoPages: () => choices('pages'),
23
+ // The admin media list route the picker already reads; projected so no media row type leaks in.
24
+ async listSeoMedia() {
25
+ const response = await transport.request({ path: `${base}/media`, method: 'GET' });
26
+ return response.media.map(({ id, slug, title, width, height, contentType }) => ({ id, title, ...(slug ? { slug } : {}), width: width ?? null, height: height ?? null, contentType: contentType ?? null }));
27
+ },
23
28
  mediaOriginalUrl: ({ id }) => transport.url({ path: `${base}/media/${encodeURIComponent(id)}/original` }),
24
29
  mediaPreviewUrl: ({ value }) => resolveMediaRefPreviewUrl({ value, mediaOriginalUrl: id => transport.url({ path: `${base}/media/${encodeURIComponent(id)}/original` }) }, { siteOrigin }),
25
30
  ...(imageTransport ? { async inspectShareImage({ url }, options = {}) {
@@ -20,21 +20,24 @@ export declare const seoMessagesEn: {
20
20
  readonly "Canonical URL": "Canonical URL";
21
21
  readonly Noindex: "Noindex";
22
22
  readonly Nofollow: "Nofollow";
23
- readonly "OG title": "OG title";
24
- readonly "OG description": "OG description";
25
- readonly "OG image": "OG image";
26
- readonly "Twitter title": "Twitter title";
27
- readonly "Twitter description": "Twitter description";
28
- readonly "Twitter image": "Twitter image";
23
+ readonly "Preview title": "Preview title";
24
+ readonly "Preview description": "Preview description";
25
+ readonly "Preview image": "Preview image";
26
+ readonly "X/Twitter title": "X/Twitter title";
27
+ readonly "X/Twitter description": "X/Twitter description";
28
+ readonly "X/Twitter image": "X/Twitter image";
29
+ readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
30
+ readonly "Same as the preview image": "Same as the preview image";
31
+ readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
29
32
  readonly "Saving\u2026": "Saving…";
30
33
  readonly "Save overrides": "Save overrides";
31
34
  readonly "Per-entry SEO": "Per-entry SEO";
32
35
  readonly "Search appearance": "Search appearance";
33
36
  readonly "Title template (must contain %s)": "Title template (must contain %s)";
34
37
  readonly "Default meta description": "Default meta description";
35
- readonly "Social sharing": "Social sharing";
36
- readonly "Default Open Graph / Twitter image": "Default Open Graph / Twitter image";
37
- readonly "Twitter @site handle": "Twitter @site handle";
38
+ readonly "Social preview": "Social preview";
39
+ readonly "Default preview image": "Default preview image";
40
+ readonly "X/Twitter @handle": "X/Twitter @handle";
38
41
  readonly Crawling: "Crawling";
39
42
  readonly "Default noindex": "Default noindex";
40
43
  readonly "Default nofollow": "Default nofollow";
@@ -47,7 +50,7 @@ export declare const seoMessagesEn: {
47
50
  readonly "View sitemap": "View sitemap";
48
51
  readonly "Loading SEO settings\u2026": "Loading SEO settings…";
49
52
  readonly Marketing: "Marketing";
50
- readonly "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.": "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.";
53
+ readonly "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.";
51
54
  readonly "Choose image": "Choose image";
52
55
  readonly Remove: "Remove";
53
56
  readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
@@ -75,5 +78,7 @@ export declare const seoMessagesEn: {
75
78
  readonly "failed to load the sitemap": "failed to load the sitemap";
76
79
  readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
77
80
  readonly "Selected image": "Selected image";
81
+ readonly "Using site default: {name}": "Using site default: {name}";
82
+ readonly "Using image from page content: {name}": "Using image from page content: {name}";
78
83
  };
79
84
  //# sourceMappingURL=messages.en.d.ts.map
@@ -20,21 +20,24 @@ export const seoMessagesEn = {
20
20
  "Canonical URL": "Canonical URL",
21
21
  "Noindex": "Noindex",
22
22
  "Nofollow": "Nofollow",
23
- "OG title": "OG title",
24
- "OG description": "OG description",
25
- "OG image": "OG image",
26
- "Twitter title": "Twitter title",
27
- "Twitter description": "Twitter description",
28
- "Twitter image": "Twitter image",
23
+ "Preview title": "Preview title",
24
+ "Preview description": "Preview description",
25
+ "Preview image": "Preview image",
26
+ "X/Twitter title": "X/Twitter title",
27
+ "X/Twitter description": "X/Twitter description",
28
+ "X/Twitter image": "X/Twitter image",
29
+ "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)",
30
+ "Same as the preview image": "Same as the preview image",
31
+ "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter",
29
32
  "Saving…": "Saving…",
30
33
  "Save overrides": "Save overrides",
31
34
  "Per-entry SEO": "Per-entry SEO",
32
35
  "Search appearance": "Search appearance",
33
36
  "Title template (must contain %s)": "Title template (must contain %s)",
34
37
  "Default meta description": "Default meta description",
35
- "Social sharing": "Social sharing",
36
- "Default Open Graph / Twitter image": "Default Open Graph / Twitter image",
37
- "Twitter @site handle": "Twitter @site handle",
38
+ "Social preview": "Social preview",
39
+ "Default preview image": "Default preview image",
40
+ "X/Twitter @handle": "X/Twitter @handle",
38
41
  "Crawling": "Crawling",
39
42
  "Default noindex": "Default noindex",
40
43
  "Default nofollow": "Default nofollow",
@@ -47,7 +50,7 @@ export const seoMessagesEn = {
47
50
  "View sitemap": "View sitemap",
48
51
  "Loading SEO settings…": "Loading SEO settings…",
49
52
  "Marketing": "Marketing",
50
- "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.": "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.",
53
+ "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.",
51
54
  "Choose image": "Choose image",
52
55
  "Remove": "Remove",
53
56
  "Sitemap · {count} URLs": "Sitemap · {count} URLs",
@@ -75,5 +78,7 @@ export const seoMessagesEn = {
75
78
  "failed to load the sitemap": "failed to load the sitemap",
76
79
  "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).",
77
80
  "Selected image": "Selected image",
81
+ "Using site default: {name}": "Using site default: {name}",
82
+ "Using image from page content: {name}": "Using image from page content: {name}",
78
83
  };
79
84
  //# sourceMappingURL=messages.en.js.map
@@ -75,6 +75,8 @@ export interface SeoController {
75
75
  }
76
76
  export interface EntryPickerController {
77
77
  entries: readonly AdminPost[] | null;
78
+ /** Searchable-picker rows (grouped Pages/Posts); null while entries load. */
79
+ options?: import('./rules.js').SeoEntryPickerOption[] | null;
78
80
  error: string | null;
79
81
  }
80
82
  export interface SeoEntryPanelOptions {
@@ -98,6 +100,9 @@ export interface SeoEntryPanelController {
98
100
  * `null` for each one they emptied. An untouched field is ABSENT, never `null`: "unchanged" must
99
101
  * never become "clear". */
100
102
  touched: SeoEntryOverridesPatch;
103
+ /** The social preview image comes from content or the site default (`ogImageInherited`, rules.ts). Optional
104
+ * for injected legacy views. */
105
+ ogImageInherited?: false | 'default' | 'content';
101
106
  }
102
107
  export interface SeoEntrySectionController {
103
108
  entryId: string;
@@ -153,6 +158,12 @@ export interface MediaRefFieldController {
153
158
  /** The field's current value resolved to a thumbnail `<img src>`, or `null` when the value is
154
159
  * empty or unparseable — see `resolveMediaRefPreviewUrl`'s own doc. */
155
160
  previewUrl: string | null;
161
+ /** The chosen file's display name and pixel size (`describeMediaRef`), or null when empty.
162
+ * Optional for injected legacy views. */
163
+ file?: {
164
+ name: string;
165
+ size: string | null;
166
+ } | null;
156
167
  /** Shared diagnostics for defaults, OG and Twitter fields; optional for injected legacy views. */
157
168
  warning?: string | null;
158
169
  onPreviewLoad?: (event: {
@@ -1,7 +1,7 @@
1
1
  import { useWiredMediaRefField } from "../hooks/MediaRefField.hooks.js";
2
2
  /**
3
- * @file `MediaRefField` — a text input for an `{assetId}:{transformName}` media reference (or a
4
- * pasted absolute URL), plus a "Choose image" affordance that opens the existing
3
+ * @file `MediaRefField` — an `{assetId}:{transformName}` media reference (or an absolute URL),
4
+ * shown as the chosen file's thumbnail and name, plus a "Choose image" affordance that opens the existing
5
5
  * `MediaPickerDialog`, a thumbnail preview of the current value, and a Remove control.
6
6
  *
7
7
  * Built for `react/pages/SeoPage.tsx`'s `defaultOgImage`/`ogImage`/`twitterImage` fields (SPEC intent: "make the OG
@@ -16,11 +16,15 @@ import { useWiredMediaRefField } from "../hooks/MediaRefField.hooks.js";
16
16
  * 2026-10-08 polish: the original editable-input rationale above still applies to pasted URLs
17
17
  * and empty fields. A selected library reference now shows plain copy with its preview; Remove
18
18
  * returns to manual entry. A hidden input preserves the exact FormData value on defaults saves.
19
+ * 2026-10-15: superseded — the input is removed entirely (owner: the read-only box "looks
20
+ * useless"). An existing pasted URL still displays (by its file name) and still saves unchanged;
21
+ * a new URL can no longer be typed here, only chosen from the library.
19
22
  */
20
23
  export interface MediaRefFieldProps {
21
24
  locale: string;
22
- /** `<input id>`/`<label htmlFor>` pairing — same `getByLabelText`-friendly shape `react/pages/SeoPage.tsx`'s
23
- * other explicit-`htmlFor` fields already use. */
25
+ /** Base id: the caption is `{id}-label` and names the row's `role="group"`; the hidden input (when
26
+ * `name` is set) carries the id itself. Was an `<input id>`/`<label htmlFor>` pair until the text
27
+ * box was removed (2026-10-15). */
24
28
  id: string;
25
29
  /** Forwarded to the `<input name>` only when set — `defaultOgImage` needs this (its `<form>` reads
26
30
  * `FormData` by name on submit); the per-entry `ogImage`/`twitterImage` fields do not (they save
@@ -32,9 +36,17 @@ export interface MediaRefFieldProps {
32
36
  /** This field's own base handle — appended `-choose`/`-clear`/`-dialog` for its own controls, and
33
37
  * forwarded as-is to the `<input>` itself. Omit to leave every element here untagged. */
34
38
  agentHandle?: string;
39
+ /** Shown in the file slot while no image is set — for a field that inherits another field's
40
+ * image when empty (the X image falls back to the social preview image). */
41
+ emptyHint?: string;
42
+ /** The value comes from the site default or page content, with no SEO override of its own: the file
43
+ * shows muted as "Using site default: …" or "Using image from page content: …"; size and Remove
44
+ * are hidden — there is no SEO override to remove. "Choose image" is how an override starts
45
+ * (owner, 2026-10-15). */
46
+ inherited?: false | 'default' | 'content';
35
47
  /** Injectable seam for the picker's own state — same convention as `MediaPickerDialog`'s own
36
48
  * `useDialog` prop. Defaults to the real {@link useWiredMediaRefField}. */
37
49
  useField?: typeof useWiredMediaRefField;
38
50
  }
39
- export declare function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, useField, }: MediaRefFieldProps, _optional?: Record<string, never>): import("react").JSX.Element;
51
+ export declare function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, emptyHint, inherited, useField, }: MediaRefFieldProps, _optional?: Record<string, never>): import("react").JSX.Element;
40
52
  //# sourceMappingURL=MediaRefField.d.ts.map
@@ -1,12 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { agentHandle } from "@jini-ai/agentic";
3
+ import { interpolate } from "@jini-ai/ui/i18n";
3
4
  import { MediaPickerBridge } from "./MediaPickerBridge.js";
4
5
  import { useSeoCopy } from "../hooks/SeoPorts.hooks.js";
5
6
  import { useWiredMediaRefField, useMediaRefPresentation, handleSpread } from "../hooks/MediaRefField.hooks.js";
6
- export function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, useField = useWiredMediaRefField, }, _optional = {}) {
7
+ export function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, emptyHint, inherited = false, useField = useWiredMediaRefField, }, _optional = {}) {
7
8
  const t = useSeoCopy();
8
- const { Picker = MediaPickerBridge, storedReference } = useMediaRefPresentation({ value });
9
- const { pickerOpen, openPicker, closePicker, handleSelect, clear, previewUrl, accept, warning, onPreviewLoad, onPreviewError } = useField({ value, onChange });
10
- return (_jsxs("div", { className: "jini-field jini-media-ref-field", children: [_jsx("label", { className: "jini-field-label", htmlFor: id, children: label }), _jsxs("div", { className: "jini-media-ref-field-row", children: [_jsx("input", { className: "jini-input", id: id, name: storedReference ? undefined : name, value: storedReference ? t({ locale, key: "Selected image" }) : value, readOnly: storedReference, "aria-describedby": warning ? `${id}-warning` : undefined, onChange: (e) => onChange(e.target.value), ...(base ? agentHandle({ handle: base }, { role: "field", label }) : {}) }), storedReference && name ? _jsx("input", { type: "hidden", name: name, value: value }) : null, _jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: openPicker, ...handleSpread({ ...(base === undefined ? {} : { base }), suffix: "choose", label: `Choose an image for: ${label}` }), children: t({ locale: locale, key: "Choose image" }) }), value ? (_jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: clear, ...handleSpread({ ...(base === undefined ? {} : { base }), suffix: "clear", label: `Remove the selected image for: ${label}` }), children: t({ locale: locale, key: "Remove" }) })) : null] }), previewUrl ? _jsx("img", { className: "jini-media-ref-field-preview", src: previewUrl, alt: `${label} preview`, onLoad: onPreviewLoad, onError: onPreviewError }, previewUrl) : null, warning ? _jsx("p", { id: `${id}-warning`, className: "jini-notice jini-notice-warning", role: "status", children: warning }) : null, pickerOpen ? (_jsx(Picker, { onSelect: handleSelect, onCancel: closePicker, accept: accept, agentHandle: base ? `${base}-dialog` : undefined })) : null] }));
9
+ const { Picker = MediaPickerBridge } = useMediaRefPresentation({ value });
10
+ const { pickerOpen, openPicker, closePicker, handleSelect, clear, previewUrl, file, accept, onPreviewLoad, onPreviewError } = useField({ value, onChange });
11
+ const labelId = `${id}-label`;
12
+ /* One compact row: thumbnail, the file's name and size, then Choose/Remove kept together in their
13
+ own group so a narrow column wraps the pair as one unit instead of stranding Remove on a line of
14
+ its own (owner, 2026-10-15). The thumbnail used to sit under the row at 4rem, repeated per field.
15
+ `warning` (share-image size/byte/HTTP diagnostics) is still computed by the hook but no longer
16
+ painted: the owner called the yellow callout under every image "completely useless".
17
+ 2026-10-15 (round 3): the text box is gone — for a library pick it only ever said a read-only
18
+ "Selected image" ("i can't change it and looks useless"), so the row names the file instead and
19
+ the picker is the one way in. The exact value still rides a hidden input for FormData saves. */
20
+ return (_jsxs("div", { className: "jini-field jini-media-ref-field", children: [_jsx("span", { className: "jini-field-label", id: labelId, children: label }), _jsxs("div", { className: "jini-media-ref-field-row", role: "group", "aria-labelledby": labelId, ...(base ? agentHandle({ handle: base }, { role: "region", label }) : {}), children: [file ? (_jsxs("span", { className: inherited ? "jini-media-ref-field-file jini-media-ref-field-file-inherited" : "jini-media-ref-field-file", children: [previewUrl ? _jsx("img", { className: "jini-media-ref-field-preview", src: previewUrl, alt: `${label} preview`, onLoad: onPreviewLoad, onError: onPreviewError }, previewUrl) : null, _jsx("span", { className: "jini-media-ref-field-name", title: file.name, children: inherited ? interpolate({ template: t({ locale: locale, key: inherited === 'content' ? "Using image from page content: {name}" : "Using site default: {name}", vars: { name: file.name } }), vars: { name: file.name } }) : file.name }), file.size && !inherited ? _jsx("span", { className: "jini-media-ref-field-size", children: file.size }) : null] })) : (emptyHint && _jsx("span", { className: "jini-media-ref-field-empty", children: emptyHint })), name ? _jsx("input", { type: "hidden", id: id, name: name, value: value }) : null, _jsxs("span", { className: "jini-media-ref-field-actions", children: [_jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: openPicker, ...handleSpread({ ...(base === undefined ? {} : { base }), suffix: "choose", label: `Choose an image for: ${label}` }), children: t({ locale: locale, key: "Choose image" }) }), value && !inherited ? (_jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: clear, ...handleSpread({ ...(base === undefined ? {} : { base }), suffix: "clear", label: `Remove the selected image for: ${label}` }), children: t({ locale: locale, key: "Remove" }) })) : null] })] }), pickerOpen ? (_jsx(Picker, { onSelect: handleSelect, onCancel: closePicker, accept: accept, agentHandle: base ? `${base}-dialog` : undefined })) : null] }));
11
21
  }
12
22
  //# sourceMappingURL=MediaRefField.js.map
@@ -3,16 +3,24 @@ import { useShareImageWarning } from './use-share-image-warning.hooks.js';
3
3
  import { agentHandle } from '@jini-ai/agentic';
4
4
  import { useController } from '../../../core/react/use-controller.js';
5
5
  import { createMediaRefController } from '../../controllers/media-ref.controller.js';
6
- import { isStoredMediaRef, resolveMediaRefPreviewUrl } from '../../rules.js';
6
+ import { describeMediaRef, isStoredMediaRef, resolveMediaRefPreviewUrl } from '../../rules.js';
7
+ import { useFetchQuery } from '@jini-ai/ui/fetch-query';
8
+ import { extensionForMimeType } from '@jini-ai/ui';
9
+ import { SEO_KEYS } from './SeoQuery.hooks.js';
7
10
  import { useSeoPorts, useSeoOptions } from './SeoPorts.hooks.js';
8
11
  export function useMediaRefField({ value, onChange, api }, { t } = {}) {
9
12
  const change = useRef(onChange);
10
13
  change.current = onChange;
11
14
  const { controller, snapshot } = useController({ create: () => createMediaRefController({ onChange: value => change.current(value) }), dependencies: [] });
12
15
  const previewUrl = api.mediaPreviewUrl ? api.mediaPreviewUrl({ value }) : resolveMediaRefPreviewUrl({ value, mediaOriginalUrl: id => api.mediaOriginalUrl({ id }) });
13
- const diagnostics = useShareImageWarning({ value, previewUrl, api }, { t });
16
+ const { naturalWidth, naturalHeight, ...diagnostics } = useShareImageWarning({ value, previewUrl, api }, { t });
17
+ // One cached library read shared by every image field on the screen; only a library reference
18
+ // needs it (a URL names itself), and a host without `listSeoMedia` falls back to the slug.
19
+ const list = api.listSeoMedia;
20
+ const media = useFetchQuery({ key: SEO_KEYS.media, fetch: () => list ? list.call(api, {}) : Promise.resolve([]) }, { enabled: Boolean(list) && isStoredMediaRef({ value }) });
14
21
  return {
15
22
  ...diagnostics,
23
+ file: describeMediaRef({ value, media: media.data }, { width: naturalWidth, height: naturalHeight, t, extensionFor: extensionForMimeType }),
16
24
  pickerOpen: snapshot?.pickerOpen ?? false,
17
25
  openPicker: () => controller?.openPicker({}), closePicker: () => controller?.closePicker({}),
18
26
  handleSelect: item => controller?.handleSelect({ item }), clear: () => controller?.clear({}),
@@ -29,6 +29,7 @@ export declare function useAnalyzePanel({ analysis }: {
29
29
  analysis: SeoEntryAnalysis;
30
30
  }, _optional?: Record<string, never>): {
31
31
  sortedIssues: import("../../../core/index.js").AdminSeoIssue[];
32
+ tone: "warning" | "success" | "failure";
32
33
  };
33
34
  /** All seven defaults remain in one FormData submission; tabs never split this form. */
34
35
  export declare function useSeoDefaults({ save }: {
@@ -1,6 +1,6 @@
1
1
  import { createElement } from 'react';
2
2
  import { SeoDefaultsIcon, SeoSitemapIcon, SeoEntriesIcon } from '../components/SeoIcons.js';
3
- import { buildSeoSettingsPatch, resolveSeoTabId, sortIssuesBySeverity } from '../../rules.js';
3
+ import { buildSeoSettingsPatch, resolveSeoTabId, sortIssuesBySeverity, scoreTone } from '../../rules.js';
4
4
  import { useSeoOptions } from './SeoPorts.hooks.js';
5
5
  export { buildSeoSettingsPatch, resolveSeoTabId } from '../../rules.js';
6
6
  export function resolveSeoTabs(_required = {}, { t = key => key } = {}) {
@@ -20,7 +20,7 @@ export function useSeoTabs({ tabId }, _optional = {}) {
20
20
  return { activeTabId: resolveSeoTabId({ tabId }), tabs: resolveSeoTabs({}, { t }) };
21
21
  }
22
22
  export function useAnalyzePanel({ analysis }, _optional = {}) {
23
- return { sortedIssues: sortIssuesBySeverity({ issues: analysis.issues }) };
23
+ return { sortedIssues: sortIssuesBySeverity({ issues: analysis.issues }), tone: scoreTone({ score: analysis.score }) };
24
24
  }
25
25
  /** All seven defaults remain in one FormData submission; tabs never split this form. */
26
26
  export function useSeoDefaults({ save }, _optional = {}) {
@@ -8,6 +8,7 @@ export declare const SEO_KEYS: {
8
8
  }, _optional?: Record<string, never>) => readonly ["seo", "entry", string];
9
9
  choices: readonly ["seo", "choices"];
10
10
  sitemap: readonly ["seo", "sitemap"];
11
+ media: readonly ["seo", "media"];
11
12
  };
12
13
  /** Controllers keep their draft/error contracts; these injected ports share the provider cache.
13
14
  * Reads omit a controller's abort signal because another mounted consumer can own the same read.
@@ -7,6 +7,7 @@ export const SEO_KEYS = {
7
7
  entry: ({ entryId }, _optional = {}) => ['seo', 'entry', entryId],
8
8
  choices: ['seo', 'choices'],
9
9
  sitemap: ['seo', 'sitemap'],
10
+ media: ['seo', 'media'],
10
11
  };
11
12
  /** Controllers keep their draft/error contracts; these injected ports share the provider cache.
12
13
  * Reads omit a controller's abort signal because another mounted consumer can own the same read.
@@ -2,13 +2,18 @@ import { describeSeoError } from '../../models.js';
2
2
  import { useFetchQuery } from '@jini-ai/ui/fetch-query';
3
3
  import { SEO_KEYS } from './SeoQuery.hooks.js';
4
4
  import { initialEntryPickerState } from '../../models.js';
5
+ import { entryPickerOptions } from '../../rules.js';
5
6
  import { useSeoOptions, useSeoPorts } from './SeoPorts.hooks.js';
6
7
  export function useEntryPicker({ api }, { t, describeError } = {}) {
7
8
  const query = useFetchQuery({ key: SEO_KEYS.choices, fetch: async () => {
8
9
  const [posts, pages] = await Promise.all([api.listSeoPosts({}), api.listSeoPages({})]);
9
- return [...posts, ...pages];
10
+ return { posts, pages };
10
11
  } });
11
- return { ...initialEntryPickerState, entries: query.data ?? null, error: query.error ? (describeError ?? describeSeoError)({ error: query.error, fallback: t?.('failed to load entries') ?? 'failed to load entries' }) : null };
12
+ const data = query.data;
13
+ return { ...initialEntryPickerState,
14
+ entries: data ? [...data.posts, ...data.pages] : null,
15
+ options: data ? entryPickerOptions(data, { t }) : null,
16
+ error: query.error ? (describeError ?? describeSeoError)({ error: query.error, fallback: t?.('failed to load entries') ?? 'failed to load entries' }) : null };
12
17
  }
13
18
  export function useWiredEntryPicker(_required = {}, _optional = {}) {
14
19
  const { seoApi: api } = useSeoPorts();
@@ -1,9 +1,11 @@
1
1
  import { describeSeoError } from '../../models.js';
2
2
  import { useEffect, useRef } from 'react';
3
- import { useSeoEntryQuery } from './SeoQuery.hooks.js';
3
+ import { useSeoEntryQuery, SEO_KEYS } from './SeoQuery.hooks.js';
4
+ import { useFetchQuery } from '@jini-ai/ui/fetch-query';
4
5
  import { useController } from '../../../core/react/use-controller.js';
5
6
  import { createSeoEntryController } from '../../controllers/seo-entry.controller.js';
6
7
  import { initialSeoEntryState } from '../../models.js';
8
+ import { inheritedXFor, ogImageInherited } from '../../rules.js';
7
9
  import { useSeoOptions, useSeoPorts } from './SeoPorts.hooks.js';
8
10
  export function useSeoEntryPanel({ api, entryId }, { t, describeError } = {}) {
9
11
  const cached = useSeoEntryQuery({ api, entryId });
@@ -17,11 +19,20 @@ export function useSeoEntryPanel({ api, entryId }, { t, describeError } = {}) {
17
19
  if (controller && cached.query.data)
18
20
  controller.set({ patch: cached.query.data });
19
21
  }, [controller, cached.query.data]);
22
+ // The site defaults read the Site defaults tab already caches; only the default image is used here.
23
+ const settings = useFetchQuery({ key: SEO_KEYS.settings, fetch: () => api.getSeoSettings({}) });
20
24
  const state = snapshot ?? initialSeoEntryState;
21
25
  return {
22
26
  ...state,
27
+ ogImageInherited: state.resolved ? ogImageInherited({ meta: state.resolved, defaultOgImage: settings.data?.defaultOgImage, touched: state.touched }) : false,
23
28
  fieldValue: (key, resolvedValue) => controller ? controller.fieldValue({ key, resolvedValue }) : resolvedValue,
24
- setField: (key, value) => controller?.setField({ key, value }),
29
+ setField: (key, value) => {
30
+ // An edit to the social preview also clears an X field that was following it (rules.ts).
31
+ const xKey = state.resolved ? inheritedXFor({ key, meta: state.resolved, touched: state.touched }) : null;
32
+ controller?.setField({ key, value });
33
+ if (xKey)
34
+ controller?.setField({ key: xKey, value: '' });
35
+ },
25
36
  save: async () => { await controller?.save({}); },
26
37
  };
27
38
  }
@@ -15,5 +15,8 @@ export declare function useShareImageWarning({ value, previewUrl, api }: {
15
15
  api: AdminSeoPort;
16
16
  }, { t }?: {
17
17
  t?: SeoTranslator | undefined;
18
- }): Pick<MediaRefFieldController, 'warning' | 'onPreviewLoad' | 'onPreviewError'>;
18
+ }): Pick<MediaRefFieldController, 'warning' | 'onPreviewLoad' | 'onPreviewError'> & {
19
+ naturalWidth?: number | undefined;
20
+ naturalHeight?: number | undefined;
21
+ };
19
22
  //# sourceMappingURL=use-share-image-warning.hooks.d.ts.map
@@ -62,6 +62,7 @@ export function useShareImageWarning({ value, previewUrl, api }, { t = key => ke
62
62
  const checked = inspection?.url === previewUrl ? inspection : null;
63
63
  const error = checked?.error;
64
64
  return {
65
+ naturalWidth: source?.width, naturalHeight: source?.height,
65
66
  warning: shareImageWarning({ value, previewUrl, width: source?.width, height: source?.height, previewFailed: source?.failed, byteSize: checked?.byteSize,
66
67
  error: error ? interpolate({ template: t(error.key, error.vars), vars: error.vars ?? {} }) : undefined }, { t }),
67
68
  onPreviewLoad: ({ currentTarget }) => {
@@ -36,21 +36,24 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
36
36
  readonly "Canonical URL": "Canonical URL";
37
37
  readonly Noindex: "Noindex";
38
38
  readonly Nofollow: "Nofollow";
39
- readonly "OG title": "OG title";
40
- readonly "OG description": "OG description";
41
- readonly "OG image": "OG image";
42
- readonly "Twitter title": "Twitter title";
43
- readonly "Twitter description": "Twitter description";
44
- readonly "Twitter image": "Twitter image";
39
+ readonly "Preview title": "Preview title";
40
+ readonly "Preview description": "Preview description";
41
+ readonly "Preview image": "Preview image";
42
+ readonly "X/Twitter title": "X/Twitter title";
43
+ readonly "X/Twitter description": "X/Twitter description";
44
+ readonly "X/Twitter image": "X/Twitter image";
45
+ readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
46
+ readonly "Same as the preview image": "Same as the preview image";
47
+ readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
45
48
  readonly "Saving\u2026": "Saving…";
46
49
  readonly "Save overrides": "Save overrides";
47
50
  readonly "Per-entry SEO": "Per-entry SEO";
48
51
  readonly "Search appearance": "Search appearance";
49
52
  readonly "Title template (must contain %s)": "Title template (must contain %s)";
50
53
  readonly "Default meta description": "Default meta description";
51
- readonly "Social sharing": "Social sharing";
52
- readonly "Default Open Graph / Twitter image": "Default Open Graph / Twitter image";
53
- readonly "Twitter @site handle": "Twitter @site handle";
54
+ readonly "Social preview": "Social preview";
55
+ readonly "Default preview image": "Default preview image";
56
+ readonly "X/Twitter @handle": "X/Twitter @handle";
54
57
  readonly Crawling: "Crawling";
55
58
  readonly "Default noindex": "Default noindex";
56
59
  readonly "Default nofollow": "Default nofollow";
@@ -63,7 +66,7 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
63
66
  readonly "View sitemap": "View sitemap";
64
67
  readonly "Loading SEO settings\u2026": "Loading SEO settings…";
65
68
  readonly Marketing: "Marketing";
66
- readonly "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.": "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.";
69
+ readonly "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.";
67
70
  readonly "Choose image": "Choose image";
68
71
  readonly Remove: "Remove";
69
72
  readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
@@ -91,6 +94,8 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
91
94
  readonly "failed to load the sitemap": "failed to load the sitemap";
92
95
  readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
93
96
  readonly "Selected image": "Selected image";
97
+ readonly "Using site default: {name}": "Using site default: {name}";
98
+ readonly "Using image from page content: {name}": "Using image from page content: {name}";
94
99
  };
95
100
  pages: {
96
101
  readonly settings: {
@@ -158,21 +163,24 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
158
163
  readonly "Canonical URL": "Canonical URL";
159
164
  readonly Noindex: "Noindex";
160
165
  readonly Nofollow: "Nofollow";
161
- readonly "OG title": "OG title";
162
- readonly "OG description": "OG description";
163
- readonly "OG image": "OG image";
164
- readonly "Twitter title": "Twitter title";
165
- readonly "Twitter description": "Twitter description";
166
- readonly "Twitter image": "Twitter image";
166
+ readonly "Preview title": "Preview title";
167
+ readonly "Preview description": "Preview description";
168
+ readonly "Preview image": "Preview image";
169
+ readonly "X/Twitter title": "X/Twitter title";
170
+ readonly "X/Twitter description": "X/Twitter description";
171
+ readonly "X/Twitter image": "X/Twitter image";
172
+ readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
173
+ readonly "Same as the preview image": "Same as the preview image";
174
+ readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
167
175
  readonly "Saving\u2026": "Saving…";
168
176
  readonly "Save overrides": "Save overrides";
169
177
  readonly "Per-entry SEO": "Per-entry SEO";
170
178
  readonly "Search appearance": "Search appearance";
171
179
  readonly "Title template (must contain %s)": "Title template (must contain %s)";
172
180
  readonly "Default meta description": "Default meta description";
173
- readonly "Social sharing": "Social sharing";
174
- readonly "Default Open Graph / Twitter image": "Default Open Graph / Twitter image";
175
- readonly "Twitter @site handle": "Twitter @site handle";
181
+ readonly "Social preview": "Social preview";
182
+ readonly "Default preview image": "Default preview image";
183
+ readonly "X/Twitter @handle": "X/Twitter @handle";
176
184
  readonly Crawling: "Crawling";
177
185
  readonly "Default noindex": "Default noindex";
178
186
  readonly "Default nofollow": "Default nofollow";
@@ -185,7 +193,7 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
185
193
  readonly "View sitemap": "View sitemap";
186
194
  readonly "Loading SEO settings\u2026": "Loading SEO settings…";
187
195
  readonly Marketing: "Marketing";
188
- readonly "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.": "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.";
196
+ readonly "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.";
189
197
  readonly "Choose image": "Choose image";
190
198
  readonly Remove: "Remove";
191
199
  readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
@@ -213,6 +221,8 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
213
221
  readonly "failed to load the sitemap": "failed to load the sitemap";
214
222
  readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
215
223
  readonly "Selected image": "Selected image";
224
+ readonly "Using site default: {name}": "Using site default: {name}";
225
+ readonly "Using image from page content: {name}": "Using image from page content: {name}";
216
226
  };
217
227
  readonly pages: {
218
228
  readonly settings: {
@@ -281,21 +291,24 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
281
291
  readonly "Canonical URL": "Canonical URL";
282
292
  readonly Noindex: "Noindex";
283
293
  readonly Nofollow: "Nofollow";
284
- readonly "OG title": "OG title";
285
- readonly "OG description": "OG description";
286
- readonly "OG image": "OG image";
287
- readonly "Twitter title": "Twitter title";
288
- readonly "Twitter description": "Twitter description";
289
- readonly "Twitter image": "Twitter image";
294
+ readonly "Preview title": "Preview title";
295
+ readonly "Preview description": "Preview description";
296
+ readonly "Preview image": "Preview image";
297
+ readonly "X/Twitter title": "X/Twitter title";
298
+ readonly "X/Twitter description": "X/Twitter description";
299
+ readonly "X/Twitter image": "X/Twitter image";
300
+ readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
301
+ readonly "Same as the preview image": "Same as the preview image";
302
+ readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
290
303
  readonly "Saving\u2026": "Saving…";
291
304
  readonly "Save overrides": "Save overrides";
292
305
  readonly "Per-entry SEO": "Per-entry SEO";
293
306
  readonly "Search appearance": "Search appearance";
294
307
  readonly "Title template (must contain %s)": "Title template (must contain %s)";
295
308
  readonly "Default meta description": "Default meta description";
296
- readonly "Social sharing": "Social sharing";
297
- readonly "Default Open Graph / Twitter image": "Default Open Graph / Twitter image";
298
- readonly "Twitter @site handle": "Twitter @site handle";
309
+ readonly "Social preview": "Social preview";
310
+ readonly "Default preview image": "Default preview image";
311
+ readonly "X/Twitter @handle": "X/Twitter @handle";
299
312
  readonly Crawling: "Crawling";
300
313
  readonly "Default noindex": "Default noindex";
301
314
  readonly "Default nofollow": "Default nofollow";
@@ -308,7 +321,7 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
308
321
  readonly "View sitemap": "View sitemap";
309
322
  readonly "Loading SEO settings\u2026": "Loading SEO settings…";
310
323
  readonly Marketing: "Marketing";
311
- readonly "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.": "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.";
324
+ readonly "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.";
312
325
  readonly "Choose image": "Choose image";
313
326
  readonly Remove: "Remove";
314
327
  readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
@@ -336,6 +349,8 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
336
349
  readonly "failed to load the sitemap": "failed to load the sitemap";
337
350
  readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
338
351
  readonly "Selected image": "Selected image";
352
+ readonly "Using site default: {name}": "Using site default: {name}";
353
+ readonly "Using image from page content: {name}": "Using image from page content: {name}";
339
354
  };
340
355
  readonly pages: {
341
356
  readonly settings: {
@@ -65,6 +65,8 @@ import { useWiredSeo } from "../hooks/use-seo.hooks.js";
65
65
  */
66
66
  export interface SeoSectionProps {
67
67
  title: string;
68
+ /** Optional ⓘ explanation beside the title (explanations are tips, never loose paragraphs). */
69
+ tip?: string;
68
70
  children: ReactNode;
69
71
  }
70
72
  export interface EntryPickerProps {
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useAnalyzePanel, useSeoDefaults, useSeoTabs, seoTabPanel } from "../hooks/SeoPage.hooks.js";
3
3
  import { agentHandle } from "@jini-ai/agentic";
4
4
  import { TabBar } from "@jini-ai/ui/tab-strip";
5
+ import { InfoTip, Select } from "@jini-ai/ui/admin-widgets";
5
6
  import { useSeoCopy, useSeoOptions } from "../hooks/SeoPorts.hooks.js";
6
- import { actionLabel, orEmpty } from "../../rules.js";
7
+ import { actionLabel, orEmpty, xIsCustomized, xResolvedValue } from "../../rules.js";
7
8
  import { useWiredEntryPicker } from "../hooks/use-entry-picker.hooks.js";
8
9
  import { useWiredSeoEntryPanel } from "../hooks/use-seo-entry-panel.hooks.js";
9
10
  import { useSeoEntrySection } from "../hooks/use-seo-entry-section.hooks.js";
@@ -29,31 +30,27 @@ import { SitemapModal } from "../components/SitemapModal.js";
29
30
  * overrides", which sits INSIDE the "Per-entry SEO" section — keeps its own `<h3>` and is not
30
31
  * routed through here.
31
32
  */
32
- function SeoSection({ title, children }) {
33
- return (_jsxs("section", { className: "jini-seo-section", children: [_jsx("div", { className: "jini-seo-section-head", children: _jsx("h2", { className: "jini-seo-section-title", children: title }) }), _jsx("div", { className: "jini-seo-section-body", children: children })] }));
33
+ function SeoSection({ title, tip, children }) {
34
+ return (_jsxs("section", { className: "jini-seo-section", children: [_jsx("div", { className: "jini-seo-section-head", children: _jsxs("h2", { className: "jini-seo-section-title", children: [title, tip ? _jsx(InfoTip, { label: tip }) : null] }) }), _jsx("div", { className: "jini-seo-section-body", children: children })] }));
34
35
  }
35
- /** Dropdown over every post + page, sourced from the already-existing `listPosts`/`listPages`
36
- * routes — cheapest entry-selection UX available given what's already built (REQ-06). */
36
+ /** Searchable picker over every page + post, sourced from the already-existing `listPosts`/`listPages`
37
+ * routes (REQ-06). It was a native `<select>`; with a few dozen entries that opened as one tall OS
38
+ * list with "(published)" after every row (owner, 2026-10-15: "we have a searchable dropdown
39
+ * right?"). The shared admin-widgets `Select` already types-to-filter and handles the keyboard, so
40
+ * rows are just grouped Pages/Posts with a pill only for the non-published exceptions
41
+ * (`entryPickerOptions` in `rules.ts`). */
37
42
  function EntryPicker({ locale, entryId, onChange, useEntryPickerHook = useWiredEntryPicker }) {
38
43
  const t = useSeoCopy();
39
- const { entries, error } = useEntryPickerHook();
44
+ const { entries, options, error } = useEntryPickerHook();
40
45
  if (error)
41
46
  return _jsx("div", { className: "jini-notice jini-notice-error", children: error });
42
47
  if (!entries)
43
48
  return _jsx("div", { className: "jini-notice", children: t({ locale: locale, key: "Loading entries…" }) });
44
49
  /* The "Entry" caption is no longer painted (owner, 2026-09-06: "you can actually just get rid of
45
- 'Entry'") — the placeholder option already says "Choose an entry…", so the visible word was
46
- saying it twice, and with no gap between the two it read as one smashed-together control.
47
- It stays a REAL label rather than becoming a placeholder-only select: this is the exact
48
- `<label class="a11y-label-wrap">` + `.visually-hidden` idiom `styles/editor.css` documents for
49
- the four controls a previous accessibility pass found unnamed — including, by name, a
50
- `<select>` with no accessible name at all. `display: contents` drops the wrapper's own box, so
51
- the select keeps the section body's sizing exactly as if the label were not there.
52
- The i18n key is unchanged and still passed through `t`, so nothing dangles. */
53
- return (_jsxs("label", { className: "jini-a11y-label-wrap", children: [_jsx("span", { className: "jini-visually-hidden", children: t({ locale: locale, key: "Entry" }) }), _jsxs("select", { className: "jini-select", value: entryId, onChange: (e) => onChange(e.target.value), ...agentHandle({ handle: "seo-entry-picker" }, {
54
- role: "field",
55
- label: "Choose which page or post to edit or analyze SEO for",
56
- }), children: [_jsx("option", { value: "", children: t({ locale: locale, key: "Choose an entry…" }) }), entries.map((entry) => (_jsxs("option", { value: entry.id, children: [entry.title, " (", entry.status, ")"] }, entry.id)))] })] }));
50
+ 'Entry'") — the placeholder already says "Choose an entry…", so the visible word was saying it
51
+ twice. It stays the control's accessible name (now `aria-label` on the combobox trigger) rather
52
+ than leaving a placeholder-only control with no name at all. The i18n key is unchanged. */
53
+ return (_jsx("div", { className: "jini-seo-entry-picker", children: _jsx(Select, { value: entryId, onChange: onChange, options: options ?? [], placeholder: t({ locale: locale, key: "Choose an entry…" }), "aria-label": t({ locale: locale, key: "Entry" }), agentHandle: "seo-entry-picker", translate: (key) => t({ locale, key }) }) }));
57
54
  }
58
55
  /** Read-only score + issues view (REQ-07) — exact field names read off `SeoAnalysis`/`SeoIssue`
59
56
  * (`src/seo/types.ts`), not guessed. No state of its own — only the severity sort, which lives in
@@ -61,8 +58,11 @@ function EntryPicker({ locale, entryId, onChange, useEntryPickerHook = useWiredE
61
58
  function AnalyzePanel(props) {
62
59
  const t = useSeoCopy();
63
60
  const { locale } = props;
64
- const { sortedIssues } = useAnalyzePanel({ analysis: props.analysis });
65
- return (_jsxs("div", { className: "jini-seo-analyze-panel", children: [_jsx("h3", { children: t({ locale: locale, key: "Analysis" }) }), _jsxs("p", { children: [t({ locale: locale, key: "Score:" }), " ", _jsx("strong", { children: props.analysis.score })] }), sortedIssues.length === 0 ? (_jsx("p", { className: "jini-muted-cell", children: t({ locale: locale, key: "No issues." }) })) : (_jsx("ul", { children: sortedIssues.map((issue, i) => (_jsxs("li", { children: [_jsx("span", { className: `jini-status jini-status-${issue.severity === "error" ? "failure" : issue.severity === "warning" ? "unavailable" : "success"}`, children: issue.severity }), " ", _jsx("code", { children: issue.code }), issue.field ? _jsxs("span", { className: "jini-muted-cell", children: [" (", issue.field, ")"] }) : null, " \u2014 ", issue.message] }, `${issue.code}-${i}`))) }))] }));
61
+ const { sortedIssues, tone } = useAnalyzePanel({ analysis: props.analysis });
62
+ /* Heading + score pill on one line, issues as a compact list (owner, 2026-10-15: the bare
63
+ "Score: 100 / No issues." read as unstyled text). The pill reuses the issue pills' status
64
+ tones, so the score and the issues under it speak one colour language. */
65
+ return (_jsxs("section", { className: "jini-seo-analyze-panel", "aria-labelledby": "seo-analysis-heading", children: [_jsxs("div", { className: "jini-seo-analyze-head", children: [_jsx("h3", { id: "seo-analysis-heading", children: t({ locale: locale, key: "Analysis" }) }), _jsxs("span", { className: `jini-status jini-status-${tone} jini-seo-score`, children: [t({ locale: locale, key: "Score:" }), " ", _jsx("strong", { children: props.analysis.score })] })] }), sortedIssues.length === 0 ? (_jsx("p", { className: "jini-seo-analyze-empty", children: t({ locale: locale, key: "No issues." }) })) : (_jsx("ul", { className: "jini-seo-issues", children: sortedIssues.map((issue, i) => (_jsxs("li", { className: "jini-seo-issue", children: [_jsx("span", { className: `jini-status jini-status-${issue.severity === "error" ? "failure" : issue.severity === "warning" ? "warning" : "success"}`, children: issue.severity }), _jsxs("span", { className: "jini-seo-issue-text", children: [issue.message, issue.field ? _jsxs("span", { className: "jini-muted-cell", children: [" (", issue.field, ")"] }) : null] })] }, `${issue.code}-${i}`))) }))] }));
66
66
  }
67
67
  /** Per-entry overrides edit form (REQ-06). Pre-fills from `getSeoEntry`'s resolved meta, but
68
68
  * tracks which fields the user actually touched so `putSeoEntry` only ever sends a genuine
@@ -80,14 +80,16 @@ function AnalyzePanel(props) {
80
80
  // the ceiling on their own. There is nothing to extract: splitting the eleven fields into their own
81
81
  // components would still evaluate the same fallback chains, just spread across more functions, for
82
82
  // no complexity benefit and a real loss of "one form, one place to read its fields."
83
+ // 2026-10-15: now 27 — the X fields' placeholders (the live social preview value) add two more
84
+ // flat `??` fallbacks of exactly that kind.
83
85
  function SeoEntryPanel({ locale, entryId, useSeoEntryPanelHook = useWiredSeoEntryPanel }) {
84
86
  const t = useSeoCopy();
85
- const { resolved, analysis, loadError, saving, saveError, notice, fieldValue, setField, save, touched } = useSeoEntryPanelHook({ entryId });
87
+ const { resolved, analysis, loadError, saving, saveError, notice, fieldValue, setField, save, touched, ogImageInherited = false } = useSeoEntryPanelHook({ entryId });
86
88
  if (loadError)
87
89
  return _jsx("div", { className: "jini-notice jini-notice-error", children: loadError });
88
90
  if (!resolved)
89
91
  return _jsx("div", { className: "jini-notice", children: t({ locale: locale, key: "Loading entry SEO…" }) });
90
- return (_jsxs("div", { className: "jini-seo-entry-panel", children: [_jsx("h3", { children: t({ locale: locale, key: "Per-entry overrides" }) }), _jsxs("p", { className: "jini-muted-cell", children: [t({ locale: locale, key: "Fields show the currently-effective value (author override, or site default, or derived from the entry). Only fields you change here are saved as overrides." }), " ", t({ locale: locale, key: "Empty a field and save to remove its override — the entry falls back to the site default." })] }), notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, saveError ? (_jsx("div", { className: "jini-notice jini-notice-error", role: "alert", children: saveError })) : null, _jsxs("label", { children: [t({ locale: locale, key: "Title" }), _jsx("input", { className: "jini-input", value: fieldValue("title", resolved.title) ?? "", onChange: (e) => setField("title", e.target.value), ...agentHandle({ handle: "seo-entry-title" }, { role: "field", label: "This entry's SEO title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Description" }), _jsx("textarea", { className: "jini-textarea", value: fieldValue("description", resolved.description ?? "") ?? "", onChange: (e) => setField("description", e.target.value), ...agentHandle({ handle: "seo-entry-description" }, { role: "field", label: "This entry's SEO description override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Canonical URL" }), _jsx("input", { className: "jini-input", value: fieldValue("canonical", resolved.canonical) ?? "", onChange: (e) => setField("canonical", e.target.value), ...agentHandle({ handle: "seo-entry-canonical" }, { role: "field", label: "This entry's canonical URL override" }) })] }), _jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("noindex", resolved.robots.noindex) ?? false, onChange: (e) => setField("noindex", e.target.checked), ...agentHandle({ handle: "seo-entry-noindex" }, { role: "checkbox", label: "Override this entry's robots noindex directive" }) }), t({ locale: locale, key: "Noindex" })] }), _jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("nofollow", resolved.robots.nofollow) ?? false, onChange: (e) => setField("nofollow", e.target.checked), ...agentHandle({ handle: "seo-entry-nofollow" }, { role: "checkbox", label: "Override this entry's robots nofollow directive" }) }), t({ locale: locale, key: "Nofollow" })] }), _jsxs("label", { children: [t({ locale: locale, key: "OG title" }), _jsx("input", { className: "jini-input", value: fieldValue("ogTitle", resolved.openGraph.title) ?? "", onChange: (e) => setField("ogTitle", e.target.value), ...agentHandle({ handle: "seo-entry-og-title" }, { role: "field", label: "This entry's Open Graph title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "OG description" }), _jsx("input", { className: "jini-input", value: fieldValue("ogDescription", resolved.openGraph.description ?? "") ?? "", onChange: (e) => setField("ogDescription", e.target.value), ...agentHandle({ handle: "seo-entry-og-description" }, { role: "field", label: "This entry's Open Graph description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-og-image", label: t({ locale: locale, key: "OG image" }), value: fieldValue("ogImage", resolved.openGraph.imageRef ?? resolved.openGraph.image ?? "") ?? "", onChange: (value) => setField("ogImage", value), agentHandle: "seo-entry-og-image" }), _jsxs("label", { children: [t({ locale: locale, key: "Twitter title" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterTitle", resolved.twitter.title) ?? "", onChange: (e) => setField("twitterTitle", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-title" }, { role: "field", label: "This entry's Twitter card title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Twitter description" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterDescription", resolved.twitter.description ?? "") ?? "", onChange: (e) => setField("twitterDescription", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-description" }, { role: "field", label: "This entry's Twitter card description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-twitter-image", label: t({ locale: locale, key: "Twitter image" }), value: fieldValue("twitterImage", resolved.twitter.imageRef ?? resolved.twitter.image ?? "") ?? "", onChange: (value) => setField("twitterImage", value), agentHandle: "seo-entry-twitter-image" }), _jsx("span", { className: "jini-editor-actions", children: _jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: save, disabled: saving || Object.keys(touched).length === 0, ...agentHandle({ handle: "seo-entry-save-overrides" }, { role: "button", label: "Save this entry's SEO overrides" }), children: saving ? t({ locale: locale, key: "Saving…" }) : t({ locale: locale, key: "Save overrides" }) }) }), analysis ? _jsx(AnalyzePanel, { locale: locale, analysis: analysis }) : null] }));
92
+ return (_jsxs("div", { className: "jini-seo-entry-panel", children: [_jsx("div", { className: "jini-seo-entry-head", children: _jsxs("div", { className: "jini-seo-entry-head-bar", children: [_jsx("h3", { children: t({ locale: locale, key: "Per-entry overrides" }) }), _jsx(InfoTip, { label: `${t({ locale: locale, key: "Fields show the currently-effective value (author override, or site default, or derived from the entry). Only fields you change here are saved as overrides." })} ${t({ locale: locale, key: "Empty a field and save to remove its override — the entry falls back to the site default." })}` }), _jsx("span", { className: "jini-seo-entry-head-actions", children: _jsx("button", { type: "button", className: "jini-btn", onClick: save, disabled: saving || Object.keys(touched).length === 0, ...agentHandle({ handle: "seo-entry-save-overrides" }, { role: "button", label: "Save this entry's SEO overrides" }), children: saving ? t({ locale: locale, key: "Saving…" }) : t({ locale: locale, key: "Save overrides" }) }) })] }) }), notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, saveError ? (_jsx("div", { className: "jini-notice jini-notice-error", role: "alert", children: saveError })) : null, _jsxs("fieldset", { className: "jini-seo-entry-group", children: [_jsx("legend", { children: t({ locale: locale, key: "Search appearance" }) }), _jsxs("label", { children: [t({ locale: locale, key: "Title" }), _jsx("input", { className: "jini-input", value: fieldValue("title", resolved.title) ?? "", onChange: (e) => setField("title", e.target.value), ...agentHandle({ handle: "seo-entry-title" }, { role: "field", label: "This entry's SEO title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Description" }), _jsx("textarea", { className: "jini-textarea", value: fieldValue("description", resolved.description ?? "") ?? "", onChange: (e) => setField("description", e.target.value), ...agentHandle({ handle: "seo-entry-description" }, { role: "field", label: "This entry's SEO description override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Canonical URL" }), _jsx("input", { className: "jini-input", value: fieldValue("canonical", resolved.canonical) ?? "", onChange: (e) => setField("canonical", e.target.value), ...agentHandle({ handle: "seo-entry-canonical" }, { role: "field", label: "This entry's canonical URL override" }) })] }), _jsxs("div", { className: "jini-seo-entry-checks", children: [_jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("noindex", resolved.robots.noindex) ?? false, onChange: (e) => setField("noindex", e.target.checked), ...agentHandle({ handle: "seo-entry-noindex" }, { role: "checkbox", label: "Override this entry's robots noindex directive" }) }), t({ locale: locale, key: "Noindex" })] }), _jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("nofollow", resolved.robots.nofollow) ?? false, onChange: (e) => setField("nofollow", e.target.checked), ...agentHandle({ handle: "seo-entry-nofollow" }, { role: "checkbox", label: "Override this entry's robots nofollow directive" }) }), t({ locale: locale, key: "Nofollow" })] })] })] }), _jsxs("fieldset", { className: "jini-seo-entry-group", children: [_jsxs("legend", { children: [t({ locale: locale, key: "Social preview" }), _jsx(InfoTip, { label: t({ locale: locale, key: "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Preview title" }), _jsx("input", { className: "jini-input", value: fieldValue("ogTitle", resolved.openGraph.title) ?? "", onChange: (e) => setField("ogTitle", e.target.value), ...agentHandle({ handle: "seo-entry-og-title" }, { role: "field", label: "This entry's Open Graph title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Preview description" }), _jsx("input", { className: "jini-input", value: fieldValue("ogDescription", resolved.openGraph.description ?? "") ?? "", onChange: (e) => setField("ogDescription", e.target.value), ...agentHandle({ handle: "seo-entry-og-description" }, { role: "field", label: "This entry's Open Graph description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-og-image", label: t({ locale: locale, key: "Preview image" }), value: fieldValue("ogImage", resolved.openGraph.imageRef ?? resolved.openGraph.image ?? "") ?? "", onChange: (value) => setField("ogImage", value), inherited: ogImageInherited, agentHandle: "seo-entry-og-image" }), _jsxs("details", { className: "jini-seo-x-disclosure", open: xIsCustomized({ meta: resolved }), children: [_jsx("summary", { children: t({ locale: locale, key: "Customize for X/Twitter (optional)" }) }), _jsxs("div", { className: "jini-seo-x-fields", children: [_jsxs("label", { children: [t({ locale: locale, key: "X/Twitter title" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterTitle", xResolvedValue({ key: "twitterTitle", meta: resolved })) ?? "", placeholder: fieldValue("ogTitle", resolved.openGraph.title) ?? "", onChange: (e) => setField("twitterTitle", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-title" }, { role: "field", label: "This entry's Twitter card title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "X/Twitter description" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterDescription", xResolvedValue({ key: "twitterDescription", meta: resolved })) ?? "", placeholder: fieldValue("ogDescription", resolved.openGraph.description ?? "") ?? "", onChange: (e) => setField("twitterDescription", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-description" }, { role: "field", label: "This entry's Twitter card description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-twitter-image", label: t({ locale: locale, key: "X/Twitter image" }), value: fieldValue("twitterImage", xResolvedValue({ key: "twitterImage", meta: resolved })) ?? "", emptyHint: t({ locale: locale, key: "Same as the preview image" }), onChange: (value) => setField("twitterImage", value), agentHandle: "seo-entry-twitter-image" })] })] })] }), analysis ? _jsx(AnalyzePanel, { locale: locale, analysis: analysis }) : null] }));
91
93
  }
92
94
  /** Section wrapper (REQ-06/07) — entry picker over the per-entry edit + analyze panels. This IS the
93
95
  * "Pages & posts" tab panel; it kept its name and its markup, because nothing about it changed
@@ -139,7 +141,7 @@ export function SeoDefaultsTab({ controller }, _optional = {}) {
139
141
  }) })] }), _jsxs("div", { className: "jini-field", children: [_jsx("label", { className: "jini-field-label", htmlFor: "seo-default-description", children: t({ locale: locale, key: "Default meta description" }) }), _jsx("textarea", { className: "jini-textarea", id: "seo-default-description", name: "defaultDescription", defaultValue: orEmpty({ value: settings.defaultDescription }), ...agentHandle({ handle: "seo-default-description" }, {
140
142
  role: "field",
141
143
  label: "Fallback meta description for pages that set none of their own",
142
- }) })] })] }), _jsxs(SeoSection, { title: t({ locale: locale, key: "Social sharing" }), children: [_jsx(MediaRefField, { locale: locale, id: "seo-default-og-image", name: "defaultOgImage", label: t({ locale: locale, key: "Default Open Graph / Twitter image" }), value: defaultOgImage, onChange: setDefaultOgImage, agentHandle: "seo-default-og-image" }), _jsxs("div", { className: "jini-field", children: [_jsx("label", { className: "jini-field-label", htmlFor: "seo-twitter-site", children: t({ locale: locale, key: "Twitter @site handle" }) }), _jsx("input", { className: "jini-input", id: "seo-twitter-site", name: "twitterSite", defaultValue: orEmpty({ value: settings.twitterSite }), ...agentHandle({ handle: "seo-twitter-site" }, {
144
+ }) })] })] }), _jsxs(SeoSection, { title: t({ locale: locale, key: "Social preview" }), tip: t({ locale: locale, key: "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter" }), children: [_jsx(MediaRefField, { locale: locale, id: "seo-default-og-image", name: "defaultOgImage", label: t({ locale: locale, key: "Default preview image" }), value: defaultOgImage, onChange: setDefaultOgImage, agentHandle: "seo-default-og-image" }), _jsxs("div", { className: "jini-field", children: [_jsx("label", { className: "jini-field-label", htmlFor: "seo-twitter-site", children: t({ locale: locale, key: "X/Twitter @handle" }) }), _jsx("input", { className: "jini-input", id: "seo-twitter-site", name: "twitterSite", defaultValue: orEmpty({ value: settings.twitterSite }), ...agentHandle({ handle: "seo-twitter-site" }, {
143
145
  role: "field",
144
146
  label: "The site's Twitter @handle, used in Twitter card metadata",
145
147
  }) })] })] }), _jsxs(SeoSection, { title: t({ locale: locale, key: "Crawling" }), children: [_jsxs("label", { className: "jini-form-checkbox-field", children: [_jsx("input", { className: "jini-input", type: "checkbox", name: "noindex", defaultChecked: settings.defaultRobots.noindex, ...agentHandle({ handle: "seo-default-noindex" }, {
@@ -208,7 +210,7 @@ export function Seo({ tabId, onTabChange, useSeoHook = useWiredSeo } = {}, _opti
208
210
  regenerateSitemap,
209
211
  openSitemapModal,
210
212
  };
211
- return (_jsxs("div", { className: "jini-page jini-seo-page", children: [_jsxs("div", { className: "jini-page-header", children: [_jsxs("div", { className: "jini-page-header-text", children: [_jsx("p", { className: "jini-page-kicker", children: t({ locale: locale, key: "Marketing" }) }), _jsx("h1", { className: "jini-page-title", children: "SEO" }), _jsx("p", { className: "jini-page-description", children: t({ locale: locale, key: "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives." }) })] }), _jsx("div", { className: "jini-page-actions", children: headerActions })] }), error ? _jsx("div", { className: "jini-notice jini-notice-error", children: error }) : null, notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, _jsx(TabBar, { ariaLabel: "SEO", tabs: tabs, activeId: activeTabId, onChange: changeTab, containerHandle: "seo-tab-bar" }), seoTabPanel({ activeTabId, controller, components: { SeoDefaultsTab, SeoSitemapTab, SeoEntrySection } }), sitemapModalOpen ? (_jsx(SitemapModal, { locale: locale, sitemapEnabled: settings.sitemapEnabled, regenerating: saving, onRegenerate: regenerateSitemap, onClose: closeSitemapModal })) : null] }));
213
+ return (_jsxs("div", { className: "jini-page jini-seo-page", children: [_jsxs("div", { className: "jini-page-header", children: [_jsxs("div", { className: "jini-page-header-text", children: [_jsx("p", { className: "jini-page-kicker", children: t({ locale: locale, key: "Marketing" }) }), _jsx("h1", { className: "jini-page-title", children: "SEO" }), _jsx("p", { className: "jini-page-description", children: t({ locale: locale, key: "Site-wide defaults for meta titles, descriptions, social previews, and robots directives." }) })] }), _jsx("div", { className: "jini-page-actions", children: headerActions })] }), error ? _jsx("div", { className: "jini-notice jini-notice-error", children: error }) : null, notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, _jsx(TabBar, { ariaLabel: "SEO", tabs: tabs, activeId: activeTabId, onChange: changeTab, containerHandle: "seo-tab-bar" }), seoTabPanel({ activeTabId, controller, components: { SeoDefaultsTab, SeoSitemapTab, SeoEntrySection } }), sitemapModalOpen ? (_jsx(SitemapModal, { locale: locale, sitemapEnabled: settings.sitemapEnabled, regenerating: saving, onRegenerate: regenerateSitemap, onClose: closeSitemapModal })) : null] }));
212
214
  }
213
215
  export { Seo as SeoPage };
214
216
  export default Seo;
@@ -1,5 +1,6 @@
1
1
  import type { AdminSeoIssue as SeoIssue, AdminSeoSettingsPatch as SeoSettingsPatch } from "../core/ports/seo.js";
2
2
  import type { SeoTranslator } from './models.js';
3
+ import type { AdminSeoEntryChoice, AdminSeoMediaSummary, AdminSeoMeta } from '../core/ports/seo.js';
3
4
  /**
4
5
  * Orders issues error-first, then warning, then info, matching the severities' natural urgency.
5
6
  * An unrecognized severity sorts last (falls back to `9`) rather than throwing, since this reads
@@ -37,6 +38,36 @@ export declare function overrideOrClear<V>({ value }: {
37
38
  export declare function orEmpty({ value }: {
38
39
  value: string | undefined;
39
40
  }, _optional?: Record<string, never>): string;
41
+ /** The X (twitter) fields the "Customize for X/Twitter" disclosure holds, each paired with the social
42
+ * preview (Open Graph) value it falls back to. */
43
+ export type SeoXFieldKey = 'twitterTitle' | 'twitterDescription' | 'twitterImage';
44
+ /** Whether this entry's X card differs from its social preview in any field — i.e. an X override
45
+ * is in effect, so the "Customize for X/Twitter" disclosure opens on load. (2026-10-15, owner: one
46
+ * generic social preview, X only as an optional customization.)
47
+ * @complexity O(1). */
48
+ export declare function xIsCustomized({ meta }: {
49
+ meta: AdminSeoMeta;
50
+ }, _optional?: Record<string, never>): boolean;
51
+ /** An X field's untouched box value, handed to `fieldValue` as its resolved value (so a pending
52
+ * edit or clear still wins there). An X value equal to the social preview's is inherited, so the
53
+ * box stays empty and its placeholder shows the live social value — typing there is what creates
54
+ * an X override.
55
+ * @complexity O(1). */
56
+ export declare function xResolvedValue({ key, meta }: {
57
+ key: SeoXFieldKey;
58
+ meta: AdminSeoMeta;
59
+ }, _optional?: Record<string, never>): string;
60
+ /** The X field an edit to a social preview field should carry along: when X still shows the
61
+ * preview's value and has no pending edit of its own, the edit also clears X's stored override so
62
+ * X keeps following the preview after save. Without it, an X override saved equal to the preview
63
+ * (indistinguishable here, the screen sees only effective values) would silently pin X to the old
64
+ * text. Clearing an override that does not exist is a no-op on the server.
65
+ * @complexity O(1). */
66
+ export declare function inheritedXFor({ key, meta, touched }: {
67
+ key: string;
68
+ meta: AdminSeoMeta;
69
+ touched: Readonly<Record<string, unknown>>;
70
+ }, _optional?: Record<string, never>): SeoXFieldKey | null;
40
71
  /** A pending-action button's label — `react/pages/SeoPage.tsx` uses this for both the save-settings button
41
72
  * ("Saving…"/"Save settings") and the regenerate-sitemap button ("Working…"/"Regenerate
42
73
  * sitemap"), same `pending ? … : …` shape, different copy. Also reused by `SitemapModal.tsx`'s
@@ -85,6 +116,39 @@ export declare function filterSitemapEntries({ entries, query }: {
85
116
  export declare function isStoredMediaRef({ value }: {
86
117
  value: string;
87
118
  }, _optional?: Record<string, never>): boolean;
119
+ /** What an image field shows for its value — the file's name and pixel size — in place of the raw
120
+ * `{slug}:public` reference or URL (owner, 2026-10-15: a read-only "Selected image" box "looks
121
+ * useless"). The library row wins when the host can list media; otherwise a readable slug or the
122
+ * URL's file name stands in, and an opaque id falls back to the translated "Image".
123
+ * Size prefers the stored dimensions, then the decoded preview's natural size.
124
+ * @example describeMediaRef({ value: 'sunset:public', media: [{ id: 'a1', slug: 'sunset', title: 'Sunset', width: 1200, height: 630 }] }) // { name: 'Sunset', size: '1200 × 630' }
125
+ * @complexity O(m) over the media rows; no I/O.
126
+ */
127
+ export declare function describeMediaRef({ value, media }: {
128
+ value: string;
129
+ media?: readonly AdminSeoMediaSummary[] | null | undefined;
130
+ }, { width, height, t, extensionFor }?: {
131
+ width?: number | undefined;
132
+ height?: number | undefined;
133
+ t?: SeoTranslator | undefined;
134
+ /** `image/png` → `.png`; injected (the host UI kit owns the table) so this file stays pure. */
135
+ extensionFor?: ((contentType: string) => string) | undefined;
136
+ }): {
137
+ name: string;
138
+ size: string | null;
139
+ } | null;
140
+ /** Whether an entry's social preview image comes from content or the site default, with no override
141
+ * of its own, so the field shows its source muted. "Using site default" is the original copy (owner,
142
+ * 2026-10-15). A pending edit is the entry's own, and so is a stored override the host flags
143
+ * (`imageOverridden`) even when it equals the default. A host without that flag can't tell such an
144
+ * override apart, so it reads as inherited (same image either way). Returns 'content' for an image
145
+ * the host identifies as content, 'default' for the site default, and false for an own/absent image.
146
+ * @complexity O(1). */
147
+ export declare function ogImageInherited({ meta, defaultOgImage, touched }: {
148
+ meta: AdminSeoMeta;
149
+ defaultOgImage: string | undefined;
150
+ touched: Readonly<Record<string, unknown>>;
151
+ }, _optional?: Record<string, never>): false | 'default' | 'content';
88
152
  /** Builds the `{slug}:public` reference `MediaRefField` writes into the field on selection —
89
153
  * prefers the asset's readable slug over its id (readable-slugs S5b, 2026-09-23), matching
90
154
  * `resolveSeoImageRef`'s own id-or-slug lookup server-side (`findMediaByIdOrSlug`,
@@ -159,4 +223,31 @@ export type SeoTabId = (typeof SEO_TAB_IDS)[number];
159
223
  export declare function resolveSeoTabId({ tabId }: {
160
224
  tabId?: string | null | undefined;
161
225
  }, _optional?: Record<string, never>): SeoTabId;
226
+ /** Colour band for the analysis score pill: the same success/warning/failure tones the issue pills
227
+ * use, so a 100 reads green and a failing page reads red at a glance.
228
+ * @example scoreTone({ score: 100 }) // 'success'
229
+ */
230
+ export declare function scoreTone({ score }: {
231
+ score: number;
232
+ }, _optional?: Record<string, never>): 'success' | 'warning' | 'failure';
233
+ /** One row of the searchable entry picker: grouped Pages then Posts, status only when not live. */
234
+ export interface SeoEntryPickerOption {
235
+ value: string;
236
+ label: string;
237
+ group: string;
238
+ badge?: string;
239
+ detail?: string;
240
+ }
241
+ /**
242
+ * Picker rows for the Pages & posts tab. Pages lead because they are fewer and are the usual SEO
243
+ * targets (home, about, pricing); a published entry carries no pill, so only the exceptions
244
+ * (draft, scheduled, …) draw the eye. An untitled entry stays pickable by its id.
245
+ * @example entryPickerOptions({ posts, pages }, { t }) // [{ value: 'p1', label: 'About', group: 'Pages' }, …]
246
+ */
247
+ export declare function entryPickerOptions({ posts, pages }: {
248
+ posts: readonly AdminSeoEntryChoice[];
249
+ pages: readonly AdminSeoEntryChoice[];
250
+ }, { t }?: {
251
+ t?: SeoTranslator | undefined;
252
+ }): SeoEntryPickerOption[];
162
253
  //# sourceMappingURL=rules.d.ts.map
package/dist/seo/rules.js CHANGED
@@ -46,6 +46,43 @@ export function overrideOrClear({ value }, _optional = {}) {
46
46
  export function orEmpty({ value }, _optional = {}) {
47
47
  return value ?? "";
48
48
  }
49
+ /** One entry's effective X value next to its effective social preview value, per field. Images
50
+ * compare by source ref (the resolved URL is resized), falling back to the URL for pasted ones. */
51
+ function xAndSocial({ meta }) {
52
+ return {
53
+ twitterTitle: [meta.twitter.title, meta.openGraph.title],
54
+ twitterDescription: [meta.twitter.description, meta.openGraph.description],
55
+ twitterImage: [meta.twitter.imageRef ?? meta.twitter.image, meta.openGraph.imageRef ?? meta.openGraph.image],
56
+ };
57
+ }
58
+ /** Whether this entry's X card differs from its social preview in any field — i.e. an X override
59
+ * is in effect, so the "Customize for X/Twitter" disclosure opens on load. (2026-10-15, owner: one
60
+ * generic social preview, X only as an optional customization.)
61
+ * @complexity O(1). */
62
+ export function xIsCustomized({ meta }, _optional = {}) {
63
+ return Object.values(xAndSocial({ meta })).some(([x, social]) => x !== social);
64
+ }
65
+ /** An X field's untouched box value, handed to `fieldValue` as its resolved value (so a pending
66
+ * edit or clear still wins there). An X value equal to the social preview's is inherited, so the
67
+ * box stays empty and its placeholder shows the live social value — typing there is what creates
68
+ * an X override.
69
+ * @complexity O(1). */
70
+ export function xResolvedValue({ key, meta }, _optional = {}) {
71
+ const [x, social] = xAndSocial({ meta })[key];
72
+ return x === social ? '' : x ?? '';
73
+ }
74
+ /** The X field an edit to a social preview field should carry along: when X still shows the
75
+ * preview's value and has no pending edit of its own, the edit also clears X's stored override so
76
+ * X keeps following the preview after save. Without it, an X override saved equal to the preview
77
+ * (indistinguishable here, the screen sees only effective values) would silently pin X to the old
78
+ * text. Clearing an override that does not exist is a no-op on the server.
79
+ * @complexity O(1). */
80
+ export function inheritedXFor({ key, meta, touched }, _optional = {}) {
81
+ const xKey = { ogTitle: 'twitterTitle', ogDescription: 'twitterDescription', ogImage: 'twitterImage' }[key];
82
+ if (!xKey || xKey in touched)
83
+ return null;
84
+ return xResolvedValue({ key: xKey, meta }) === '' ? xKey : null;
85
+ }
49
86
  /** A pending-action button's label — `react/pages/SeoPage.tsx` uses this for both the save-settings button
50
87
  * ("Saving…"/"Save settings") and the regenerate-sitemap button ("Working…"/"Regenerate
51
88
  * sitemap"), same `pending ? … : …` shape, different copy. Also reused by `SitemapModal.tsx`'s
@@ -126,6 +163,64 @@ export function isStoredMediaRef({ value }, _optional = {}) {
126
163
  // A URL typed one character at a time reaches "https:/" before it reaches "https://".
127
164
  return !trimmed.includes(":/") && parseMediaRefAssetId(trimmed) !== null;
128
165
  }
166
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
167
+ /** What an image field shows for its value — the file's name and pixel size — in place of the raw
168
+ * `{slug}:public` reference or URL (owner, 2026-10-15: a read-only "Selected image" box "looks
169
+ * useless"). The library row wins when the host can list media; otherwise a readable slug or the
170
+ * URL's file name stands in, and an opaque id falls back to the translated "Image".
171
+ * Size prefers the stored dimensions, then the decoded preview's natural size.
172
+ * @example describeMediaRef({ value: 'sunset:public', media: [{ id: 'a1', slug: 'sunset', title: 'Sunset', width: 1200, height: 630 }] }) // { name: 'Sunset', size: '1200 × 630' }
173
+ * @complexity O(m) over the media rows; no I/O.
174
+ */
175
+ export function describeMediaRef({ value, media }, { width, height, t = key => key, extensionFor } = {}) {
176
+ const trimmed = typeof value === 'string' ? value.trim() : '';
177
+ if (!trimmed)
178
+ return null;
179
+ const key = parseMediaRefAssetId(trimmed);
180
+ const row = key ? media?.find(item => item.id === key || item.slug === key) : undefined;
181
+ const name = row?.title ? withExtension({ name: row.title, extension: row.contentType && extensionFor ? extensionFor(row.contentType) : '' }) : fallbackMediaName({ key, value: trimmed, t });
182
+ return { name, size: pixelSize(row?.width ?? width, row?.height ?? height) };
183
+ }
184
+ /** A library title reads as a file name (owner, 2026-10-15: "it should be testing-page.png or
185
+ * whatever the extension is"). No original upload name is stored, so the extension comes from the
186
+ * stored bytes' type; a title that already ends in it is left alone. */
187
+ function withExtension({ name, extension }) {
188
+ return extension && !name.toLowerCase().endsWith(extension) ? `${name}${extension}` : name;
189
+ }
190
+ /** Whether an entry's social preview image comes from content or the site default, with no override
191
+ * of its own, so the field shows its source muted. "Using site default" is the original copy (owner,
192
+ * 2026-10-15). A pending edit is the entry's own, and so is a stored override the host flags
193
+ * (`imageOverridden`) even when it equals the default. A host without that flag can't tell such an
194
+ * override apart, so it reads as inherited (same image either way). Returns 'content' for an image
195
+ * the host identifies as content, 'default' for the site default, and false for an own/absent image.
196
+ * @complexity O(1). */
197
+ export function ogImageInherited({ meta, defaultOgImage, touched }, _optional = {}) {
198
+ if ('ogImage' in touched || meta.openGraph.imageOverridden)
199
+ return false;
200
+ const image = meta.openGraph.imageRef ?? meta.openGraph.image;
201
+ if (meta.openGraph.imageSource === 'content' && image)
202
+ return 'content';
203
+ return defaultOgImage && image === defaultOgImage ? 'default' : false;
204
+ }
205
+ function fallbackMediaName({ key, value, t }) {
206
+ if (!key)
207
+ return urlFileName(value);
208
+ return UUID_PATTERN.test(key) ? t('Image') : key;
209
+ }
210
+ function pixelSize(width, height) {
211
+ return width && height ? `${width} × ${height}` : null;
212
+ }
213
+ /** The last path segment of an image URL ("https://cdn.example/a/pic.png" → "pic.png"). */
214
+ function urlFileName(value) {
215
+ try {
216
+ const url = new URL(value, 'https://placeholder.invalid');
217
+ const last = url.pathname.split('/').filter(Boolean).pop();
218
+ return last ? decodeURIComponent(last) : url.hostname || value;
219
+ }
220
+ catch {
221
+ return value;
222
+ }
223
+ }
129
224
  /** Builds the `{slug}:public` reference `MediaRefField` writes into the field on selection —
130
225
  * prefers the asset's readable slug over its id (readable-slugs S5b, 2026-09-23), matching
131
226
  * `resolveSeoImageRef`'s own id-or-slug lookup server-side (`findMediaByIdOrSlug`,
@@ -259,4 +354,35 @@ export const SEO_TAB_IDS = ['defaults', 'sitemap', 'entries'];
259
354
  export function resolveSeoTabId({ tabId }, _optional = {}) {
260
355
  return SEO_TAB_IDS.find(id => id === tabId) ?? 'defaults';
261
356
  }
357
+ /** Colour band for the analysis score pill: the same success/warning/failure tones the issue pills
358
+ * use, so a 100 reads green and a failing page reads red at a glance.
359
+ * @example scoreTone({ score: 100 }) // 'success'
360
+ */
361
+ export function scoreTone({ score }, _optional = {}) {
362
+ if (score >= 80)
363
+ return 'success';
364
+ return score >= 50 ? 'warning' : 'failure';
365
+ }
366
+ /**
367
+ * Picker rows for the Pages & posts tab. Pages lead because they are fewer and are the usual SEO
368
+ * targets (home, about, pricing); a published entry carries no pill, so only the exceptions
369
+ * (draft, scheduled, …) draw the eye. An untitled entry stays pickable by its id.
370
+ * @example entryPickerOptions({ posts, pages }, { t }) // [{ value: 'p1', label: 'About', group: 'Pages' }, …]
371
+ */
372
+ export function entryPickerOptions({ posts, pages }, { t = key => key } = {}) {
373
+ // Two entries can share a title ("Landing Page" twice, owner 2026-10-15); both stay listed and
374
+ // each duplicate shows its slug as muted secondary text so the operator can tell them apart.
375
+ const rows = (entries, group) => {
376
+ const labelOf = (entry) => entry.title.trim() || entry.id;
377
+ const counts = new Map();
378
+ for (const entry of entries)
379
+ counts.set(labelOf(entry), (counts.get(labelOf(entry)) ?? 0) + 1);
380
+ return entries.map((entry) => ({
381
+ value: entry.id, label: labelOf(entry), group,
382
+ ...(entry.status && entry.status !== 'published' ? { badge: t(entry.status) } : {}),
383
+ ...((counts.get(labelOf(entry)) ?? 0) > 1 ? { detail: entry.slug || entry.id } : {}),
384
+ }));
385
+ };
386
+ return [...rows(pages, t('Pages')), ...rows(posts, t('Posts'))];
387
+ }
262
388
  //# sourceMappingURL=rules.js.map
@@ -29,21 +29,24 @@ export declare const seoModule: {
29
29
  readonly "Canonical URL": "Canonical URL";
30
30
  readonly Noindex: "Noindex";
31
31
  readonly Nofollow: "Nofollow";
32
- readonly "OG title": "OG title";
33
- readonly "OG description": "OG description";
34
- readonly "OG image": "OG image";
35
- readonly "Twitter title": "Twitter title";
36
- readonly "Twitter description": "Twitter description";
37
- readonly "Twitter image": "Twitter image";
32
+ readonly "Preview title": "Preview title";
33
+ readonly "Preview description": "Preview description";
34
+ readonly "Preview image": "Preview image";
35
+ readonly "X/Twitter title": "X/Twitter title";
36
+ readonly "X/Twitter description": "X/Twitter description";
37
+ readonly "X/Twitter image": "X/Twitter image";
38
+ readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
39
+ readonly "Same as the preview image": "Same as the preview image";
40
+ readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
38
41
  readonly "Saving\u2026": "Saving…";
39
42
  readonly "Save overrides": "Save overrides";
40
43
  readonly "Per-entry SEO": "Per-entry SEO";
41
44
  readonly "Search appearance": "Search appearance";
42
45
  readonly "Title template (must contain %s)": "Title template (must contain %s)";
43
46
  readonly "Default meta description": "Default meta description";
44
- readonly "Social sharing": "Social sharing";
45
- readonly "Default Open Graph / Twitter image": "Default Open Graph / Twitter image";
46
- readonly "Twitter @site handle": "Twitter @site handle";
47
+ readonly "Social preview": "Social preview";
48
+ readonly "Default preview image": "Default preview image";
49
+ readonly "X/Twitter @handle": "X/Twitter @handle";
47
50
  readonly Crawling: "Crawling";
48
51
  readonly "Default noindex": "Default noindex";
49
52
  readonly "Default nofollow": "Default nofollow";
@@ -56,7 +59,7 @@ export declare const seoModule: {
56
59
  readonly "View sitemap": "View sitemap";
57
60
  readonly "Loading SEO settings\u2026": "Loading SEO settings…";
58
61
  readonly Marketing: "Marketing";
59
- readonly "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.": "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.";
62
+ readonly "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.";
60
63
  readonly "Choose image": "Choose image";
61
64
  readonly Remove: "Remove";
62
65
  readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
@@ -84,6 +87,8 @@ export declare const seoModule: {
84
87
  readonly "failed to load the sitemap": "failed to load the sitemap";
85
88
  readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
86
89
  readonly "Selected image": "Selected image";
90
+ readonly "Using site default: {name}": "Using site default: {name}";
91
+ readonly "Using image from page content: {name}": "Using image from page content: {name}";
87
92
  };
88
93
  readonly pages: {
89
94
  readonly settings: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/admin",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
4
4
  "description": "Composable admin surface for Jini-hosted products: framework-free contracts, panel registry, and route model in `/core`; React shell and panels in `/react`. Import only the layers you need.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -440,10 +440,10 @@
440
440
  "react": "^18.3.0 || ^19.0.0",
441
441
  "react-dom": "^18.3.0 || ^19.0.0",
442
442
  "@jini-ai/agentic": "^0.4.4",
443
- "@jini-ai/ui": "^0.5.1",
443
+ "@jini-ai/ui": "^0.5.5",
444
444
  "@jini-ai/ui-kit": "^0.1.3",
445
445
  "@jini-ai/agent-plugins": "^0.4.3",
446
- "@jini-ai/cms": "^0.5.11",
446
+ "@jini-ai/cms": "^0.5.13",
447
447
  "@jini-ai/core": "^0.4.3"
448
448
  },
449
449
  "peerDependenciesMeta": {
@@ -482,12 +482,12 @@
482
482
  "react": "^19.2.0",
483
483
  "react-dom": "^19.2.0",
484
484
  "@testing-library/user-event": "^14.6.1",
485
- "@jini-ai/agent-plugins": "^0.4.6",
486
485
  "@jini-ai/agentic": "^0.4.4",
486
+ "@jini-ai/agent-plugins": "^0.4.7",
487
487
  "@jini-ai/ui-kit": "^0.1.4",
488
- "@jini-ai/cms": "^0.5.12",
489
- "@jini-ai/core": "^0.4.6",
490
- "@jini-ai/ui": "^0.5.3"
488
+ "@jini-ai/cms": "^0.5.13",
489
+ "@jini-ai/core": "^0.4.7",
490
+ "@jini-ai/ui": "^0.5.5"
491
491
  },
492
492
  "typesVersions": {
493
493
  "*": {