diff --git a/.changeset/studio-ended-collection-rule.md b/.changeset/studio-ended-collection-rule.md
new file mode 100644
index 00000000..edf9995b
--- /dev/null
+++ b/.changeset/studio-ended-collection-rule.md
@@ -0,0 +1,5 @@
+---
+"@cloudhal/cli": patch
+---
+
+Studio shows **Ended (collection rule)** beside the status of a published entry whose collection's expiry has passed (RFC-0042 §3.3, #113), in the collection list and in the entry editor's header. Until now such an entry looked plainly published while the site had already left it out of lists, or answered "not found". The badge explains itself on hover, from the keyboard and with a tap on touch screens. It names the date field, when the entry ended in the site's time zone, and what the site does: the page keeps its address but leaves lists, feeds, the sitemap and search, or the page answers "not found". Drafts, embargoed entries and entries whose own "Hide after" has passed get no badge (their own dates show in the editor's Schedule card). A translation reads its original's date only while the original is published, as the site does. The badge and its words load only for collections that have an expiry. Translations of the new words are AI drafts awaiting review.
diff --git a/apps/studio/src/components/EntryStatus.tsx b/apps/studio/src/components/EntryStatus.tsx
new file mode 100644
index 00000000..4bbfc24d
--- /dev/null
+++ b/apps/studio/src/components/EntryStatus.tsx
@@ -0,0 +1,48 @@
+/**
+ * An entry's status badge, followed by "Ended (collection rule)" when its collection's expiry has
+ * passed (RFC-0042 §3.3, DESIGN.md §2ah): in the collection list and the entry editor's header.
+ * The site comes from the shared `["site", siteId]` query. Only a collection with an expiry loads
+ * the badge's chunk and its `listings` words; every other collection shows the status alone.
+ */
+import type { ContentEntry } from "@cloudhal/core-contracts";
+import { useQuery } from "@tanstack/react-query";
+import { lazy, Suspense } from "react";
+import { withMessages } from "../i18n/index.tsx";
+import { api } from "../lib/api.ts";
+import { Status } from "./Status.tsx";
+
+const EndedBadge = lazy(() =>
+ withMessages(import("./listings/EndedBadge.tsx"), ["listings"]).then((m) => ({
+ default: m.EndedBadge,
+ })),
+);
+
+/**
+ * `entry` gives the status word (the editor's draft); `saved` (the saved entry, `entry` by
+ * default) decides the collection rule, so a half-typed date does not flip the badge.
+ */
+export function EntryStatus({
+ siteId,
+ entry,
+ saved = entry,
+}: {
+ siteId: string;
+ entry: ContentEntry;
+ saved?: ContentEntry | undefined;
+}) {
+ const site = useQuery({
+ queryKey: ["site", siteId],
+ queryFn: () => api.sites.get(siteId),
+ enabled: siteId !== "",
+ }).data;
+ return (
+
+
+ {site?.collections?.[saved.collection]?.expiry && (
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/studio/src/components/listings/EndedBadge.tsx b/apps/studio/src/components/listings/EndedBadge.tsx
new file mode 100644
index 00000000..927699c0
--- /dev/null
+++ b/apps/studio/src/components/listings/EndedBadge.tsx
@@ -0,0 +1,82 @@
+import type { ContentEntry, Site } from "@cloudhal/core-contracts";
+import { fieldLabel } from "@cloudhal/core-ts/fields";
+import { ToggletipBadge } from "@cloudhal/ui";
+import { CalendarX } from "@phosphor-icons/react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { formatDate, useI18n } from "../../i18n/index.tsx";
+import { api } from "../../lib/api.ts";
+import { zoneCity } from "../../lib/schedules.ts";
+import { useNow } from "../useNow.ts";
+import { endedByRule, gateZone, originalIdOf } from "./ended-rule.ts";
+
+/**
+ * The original a translation reads its non-localized date from: the newer of two caches. The
+ * collection list's unpaged `["entries", siteId, collection]` is read without ever fetching it,
+ * and only while it is not invalidated (while it loads, this waits rather than asking once per
+ * row); the shared `["entry", siteId, group]` query, which the editor's Fields card uses and a
+ * save writes, is asked for otherwise. So a list left stale by a save is never preferred to the
+ * saved original, and the editor asks once.
+ */
+function useGateOriginal(siteId: string, entry: ContentEntry): ContentEntry | undefined {
+ const qc = useQueryClient();
+ const id = originalIdOf(entry);
+ const listKey = ["entries", siteId, entry.collection];
+ const list = useQuery({
+ queryKey: listKey,
+ queryFn: () => api.entries.list(siteId, entry.collection),
+ enabled: false,
+ });
+ const fresh = qc.getQueryState(listKey)?.isInvalidated === false;
+ const listed = id === undefined || !fresh ? undefined : list.data?.find((e) => e.id === id);
+ const one = useQuery({
+ queryKey: ["entry", siteId, id],
+ queryFn: () => api.entries.get(siteId, id as string),
+ enabled: id !== undefined && !listed && list.fetchStatus === "idle",
+ });
+ if (id === undefined) return undefined;
+ if (!listed) return one.data;
+ return one.data && one.dataUpdatedAt >= list.dataUpdatedAt ? one.data : listed;
+}
+
+/**
+ * "Ended (collection rule)" beside a published entry's status (RFC-0042 §3.3, DESIGN.md §2ah):
+ * the entry's collection expiry has passed, so the site has left it out of lists (`unlist`) or
+ * answers "not found" (`hide`) while it is still published. The toggletip names the date field,
+ * the instant in the site's zone and what the site does. Computed from the saved site settings
+ * and the saved entry (the editor passes `query.data`, never the unsaved draft), re-checked every
+ * minute. Loaded with its `listings` words only for collections that have an expiry.
+ */
+export function EndedBadge({
+ siteId,
+ site,
+ entry,
+}: {
+ siteId: string;
+ site: Site;
+ entry: ContentEntry;
+}) {
+ const { t, locale } = useI18n("listings");
+ const now = useNow(60_000);
+ const original = useGateOriginal(siteId, entry);
+ const ended = endedByRule(site, entry, original, now);
+ if (!ended) return null;
+ const { gate, fieldKey, field } = ended;
+ const zone = gateZone(site);
+ const reason = t("listings.ended.reason", {
+ action: gate.action,
+ time: formatDate(locale, gate.at, { dateStyle: "long", timeStyle: "short", timeZone: zone }),
+ zone: zoneCity(zone),
+ field: field ? fieldLabel(field, locale, site.defaultLocale) : fieldKey,
+ });
+ // Kumo's Badge does not forward data attributes; the wrapper carries the action for tests.
+ return (
+
+ }
+ label={t("listings.ended.badge")}
+ reason={reason}
+ />
+
+ );
+}
diff --git a/apps/studio/src/components/listings/ended-rule.ts b/apps/studio/src/components/listings/ended-rule.ts
new file mode 100644
index 00000000..227415f9
--- /dev/null
+++ b/apps/studio/src/components/listings/ended-rule.ts
@@ -0,0 +1,65 @@
+/**
+ * "Ended (collection rule)" in Studio (RFC-0042 §3.3, #113): whether a published entry has ended
+ * under its collection's expiry, computed with the same `derivedGate` / `entryVisibility` the
+ * site uses, so the badge and the site agree to the millisecond. The entry's own state speaks
+ * first, as in the renderer preview (`gateNoticeHtml || derivedGateNoticeHtml`): drafts,
+ * embargoed entries and entries whose own "Hide after" has passed get no badge. A translation
+ * reads its original only while the original is published, as the site does
+ * (`derivedGates(site, published)`). The gate's end is exclusive, so `now === at` has already
+ * ended. Pure; loads with the badge only (`LAZY_ONLY`).
+ */
+import type { ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts";
+import {
+ type DerivedGate,
+ derivedGate,
+ entryVisibility,
+ type FieldsOriginal,
+ isKnownTimeZone,
+} from "@cloudhal/core-ts/fields";
+
+/** The entry has ended under its collection's rule: the gate, the rule's field and its definition. */
+export interface EndedByRule {
+ gate: DerivedGate;
+ fieldKey: string;
+ field: FieldDefinition | undefined;
+}
+
+/** A translation's original as the rule reads it: its values and whether it is published. */
+export type GateOriginal = FieldsOriginal & Pick;
+
+/**
+ * The collection's derived gate when it, and nothing of the entry's own, keeps the entry off the
+ * site at `now`: published, its own `availability` open, and the derived gate passed.
+ * `undefined` otherwise (no rule, no readable value, not yet ended, or the entry's own state).
+ * A draft, in-review or archived original is not read, as on the site (it takes originals from
+ * published entries only): the translation then reads only its own values, which hold no
+ * non-localized date, so it stays listed.
+ */
+export function endedByRule(
+ site: Site,
+ entry: ContentEntry,
+ original: GateOriginal | undefined,
+ now: number,
+): EndedByRule | undefined {
+ const gate = derivedGate(site, entry, original?.status === "published" ? original : undefined);
+ if (!gate) return undefined;
+ if (entryVisibility(entry, undefined, now) !== "shown") return undefined;
+ if (entryVisibility(entry, gate, now) === "shown") return undefined;
+ const settings = site.collections?.[entry.collection];
+ const fieldKey = settings?.expiry?.field ?? "";
+ return { gate, fieldKey, field: settings?.fields?.find((d) => d.key === fieldKey) };
+}
+
+/** The zone the site's gates use, for showing the instant: the site's, or UTC when unknown. */
+export function gateZone(site: Pick): string {
+ return site.timeZone && isKnownTimeZone(site.timeZone) ? site.timeZone : "UTC";
+}
+
+/**
+ * The id of the same-site original a translation reads its non-localized values from
+ * (EntryFieldsCard's `useOriginal` rule); `undefined` for originals and language editions.
+ */
+export function originalIdOf(entry: Pick): string | undefined {
+ const tr = entry.translation;
+ return tr && tr.group !== entry.id && !tr.source?.siteId ? tr.group : undefined;
+}
diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts
index d03143c9..d4b514df 100644
--- a/apps/studio/src/i18n/generated/params.ts
+++ b/apps/studio/src/i18n/generated/params.ts
@@ -368,6 +368,7 @@ export interface MessageParams {
"listings.contentList.ops.lt": { kind: "time" | "number" };
"listings.contentList.ops.lte": { kind: "time" | "number" };
"listings.createPage.exists": { path: string | number };
+ "listings.ended.reason": { action: "unlist" | "hide"; time: string | number; zone: string | number; field: string | number };
"listings.expiry.past": { count: number };
"listings.filters.any": { label: string | number };
"listings.filters.is": { label: string | number; value: string | number };
diff --git a/apps/studio/src/i18n/lazy/listings.ar.ts b/apps/studio/src/i18n/lazy/listings.ar.ts
index 9efaed88..0ef1ad83 100644
--- a/apps/studio/src/i18n/lazy/listings.ar.ts
+++ b/apps/studio/src/i18n/lazy/listings.ar.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ar `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 0091155c70e9111a
+// hash: 1359deb13435c6a1
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -49,6 +49,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"لا يطبّق الخادم المباشر لهذا الموقع الانتهاء بعد. وزّع الموقع أولًا، ثم احفظ الانتهاء مرة أخرى.",
},
+ ended: {
+ badge: "انتهى (قاعدة المجموعة)",
+ reason:
+ "{action, select, unlist {انتهى هذا الإدخال في {time} (بتوقيت {zone}) بموجب قاعدة انتهاء مجموعته على «{field}». لا يزال منشورًا وتبقى صفحته على عنوانها، لكنه خرج من القوائم والخلاصات وخريطة الموقع والبحث.} hide {انتهى هذا الإدخال في {time} (بتوقيت {zone}) بموجب قاعدة انتهاء مجموعته على «{field}». لا يزال منشورًا، لكن صفحته تجيب بـ«غير موجود»، وقد خرج من كل قائمة وخلاصة ومن البحث.} other {انتهى هذا الإدخال في {time} (بتوقيت {zone}) بموجب قاعدة انتهاء مجموعته على «{field}».}}",
+ },
structuredData: {
title: "البيانات المنظمة",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.de.ts b/apps/studio/src/i18n/lazy/listings.de.ts
index 3807fd44..4a02f6c3 100644
--- a/apps/studio/src/i18n/lazy/listings.de.ts
+++ b/apps/studio/src/i18n/lazy/listings.de.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): de `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: d026945593c61937
+// hash: c4f413070f5457f2
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -52,6 +52,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"Der Live-Server dieser Website wendet den Ablauf noch nicht an. Deploye zuerst die Website und speichere den Ablauf dann erneut.",
},
+ ended: {
+ badge: "Beendet (Sammlungsregel)",
+ reason:
+ "{action, select, unlist {Dieser Eintrag endete am {time} ({zone}-Zeit) durch die Ablaufregel seiner Sammlung für „{field}“. Er ist weiterhin veröffentlicht und seine Seite bleibt unter ihrer Adresse, aber er ist aus Listen, Feeds, der Sitemap und der Suche verschwunden.} hide {Dieser Eintrag endete am {time} ({zone}-Zeit) durch die Ablaufregel seiner Sammlung für „{field}“. Er ist weiterhin veröffentlicht, aber seine Seite antwortet mit „nicht gefunden“, und er ist aus allen Listen, Feeds und der Suche verschwunden.} other {Dieser Eintrag endete am {time} ({zone}-Zeit) durch die Ablaufregel seiner Sammlung für „{field}“.}}",
+ },
structuredData: {
title: "Strukturierte Daten",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.en.ts b/apps/studio/src/i18n/lazy/listings.en.ts
index 0beff10c..bc28106a 100644
--- a/apps/studio/src/i18n/lazy/listings.en.ts
+++ b/apps/studio/src/i18n/lazy/listings.en.ts
@@ -52,6 +52,25 @@ export const messages = {
gatesUnsupported:
"This site's live server doesn't apply expiry yet. Deploy the site first, then save the expiry again.",
},
+ /**
+ * RFC-0042 §3.3: a published entry whose collection's expiry has passed. Its status stays
+ * Published; this badge follows the status in the collection list and the editor's header.
+ */
+ ended: {
+ /**
+ * Badge beside the status in the collection list and the entry editor's header. Must read
+ * differently from an entry's own end ("Expired {time}", schedules.card.state.expired) and
+ * must say it comes from the collection rule.
+ */
+ badge: "Ended (collection rule)",
+ /**
+ * The badge's tooltip. `time` is a date and time already formatted in the site's time zone,
+ * `zone` the zone's city ("Istanbul", "UTC"), `field` the date field's label, `action`
+ * unlist or hide. Keep both branches and `other`; never put an apostrophe directly before `{`.
+ */
+ reason:
+ "{action, select, unlist {This entry ended on {time} ({zone} time) under its collection's expiry on “{field}”. It's still published and its page stays at its address, but it has left lists, feeds, the sitemap and search.} hide {This entry ended on {time} ({zone} time) under its collection's expiry on “{field}”. It's still published, but its page answers “not found”, and it has left every list, feed and search.} other {This entry ended on {time} ({zone} time) under its collection's expiry on “{field}”.}}",
+ },
structuredData: {
title: "Structured data",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.es.ts b/apps/studio/src/i18n/lazy/listings.es.ts
index 1c0ebb25..a707444d 100644
--- a/apps/studio/src/i18n/lazy/listings.es.ts
+++ b/apps/studio/src/i18n/lazy/listings.es.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): es `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 1a559d2468df917b
+// hash: f9daf913ef38b355
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -53,6 +53,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"El servidor en vivo de este sitio aún no aplica la caducidad. Despliega primero el sitio y luego vuelve a guardar la caducidad.",
},
+ ended: {
+ badge: "Finalizada (regla de la colección)",
+ reason:
+ "{action, select, unlist {Esta entrada finalizó el {time} (hora de {zone}) por la regla de caducidad de su colección sobre «{field}». Sigue publicada y su página sigue en su dirección, pero ha salido de las listas, los feeds, el sitemap y la búsqueda.} hide {Esta entrada finalizó el {time} (hora de {zone}) por la regla de caducidad de su colección sobre «{field}». Sigue publicada, pero su página responde «no encontrada» y ha salido de todas las listas, feeds y búsquedas.} other {Esta entrada finalizó el {time} (hora de {zone}) por la regla de caducidad de su colección sobre «{field}».}}",
+ },
structuredData: {
title: "Datos estructurados",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.fa.ts b/apps/studio/src/i18n/lazy/listings.fa.ts
index d9af0de4..eaf1627e 100644
--- a/apps/studio/src/i18n/lazy/listings.fa.ts
+++ b/apps/studio/src/i18n/lazy/listings.fa.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): fa `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: a4045f73a5d8d0c6
+// hash: b63ab0cfe610e162
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -51,6 +51,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"سرور زندهٔ این سایت هنوز انقضا را اعمال نمیکند. ابتدا سایت را مستقر کنید و سپس انقضا را دوباره ذخیره کنید.",
},
+ ended: {
+ badge: "پایانیافته (قاعدهٔ مجموعه)",
+ reason:
+ "{action, select, unlist {این ورودی طبق قاعدهٔ انقضای مجموعهاش بر اساس «{field}» در {time} (به وقت {zone}) به پایان رسید. هنوز منتشرشده است و صفحهاش در نشانی خود میماند، اما از فهرستها، فیدها، sitemap و جستجو بیرون رفته است.} hide {این ورودی طبق قاعدهٔ انقضای مجموعهاش بر اساس «{field}» در {time} (به وقت {zone}) به پایان رسید. هنوز منتشرشده است، اما صفحهاش پاسخ «پیدا نشد» میدهد و از همهٔ فهرستها، فیدها و جستجو بیرون رفته است.} other {این ورودی طبق قاعدهٔ انقضای مجموعهاش بر اساس «{field}» در {time} (به وقت {zone}) به پایان رسید.}}",
+ },
structuredData: {
title: "دادههای ساختاریافته",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.fr.ts b/apps/studio/src/i18n/lazy/listings.fr.ts
index 565b5b59..9308c1a3 100644
--- a/apps/studio/src/i18n/lazy/listings.fr.ts
+++ b/apps/studio/src/i18n/lazy/listings.fr.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): fr `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: f3f0c89b2f0fba4f
+// hash: a42c0843d9a6c4ae
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -53,6 +53,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"Le serveur en ligne de ce site n’applique pas encore l’expiration. Déployez d’abord le site, puis enregistrez de nouveau l’expiration.",
},
+ ended: {
+ badge: "Terminée (règle de la collection)",
+ reason:
+ "{action, select, unlist {Cette entrée a pris fin le {time} (heure de {zone}) selon la règle d’expiration de sa collection sur « {field} ». Elle est toujours publiée et sa page reste à son adresse, mais elle a quitté les listes, les flux, le sitemap et la recherche.} hide {Cette entrée a pris fin le {time} (heure de {zone}) selon la règle d’expiration de sa collection sur « {field} ». Elle est toujours publiée, mais sa page répond « introuvable », et elle a quitté toutes les listes, tous les flux et la recherche.} other {Cette entrée a pris fin le {time} (heure de {zone}) selon la règle d’expiration de sa collection sur « {field} ».}}",
+ },
structuredData: {
title: "Données structurées",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.he.ts b/apps/studio/src/i18n/lazy/listings.he.ts
index 80716eed..f9369924 100644
--- a/apps/studio/src/i18n/lazy/listings.he.ts
+++ b/apps/studio/src/i18n/lazy/listings.he.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): he `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 67b79b29a4fbf2ae
+// hash: 3209fcf2d9b89a8b
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -47,6 +47,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"השרת החי של האתר הזה עדיין לא מחיל תפוגה. פרסו קודם את האתר, ואז שמרו את התפוגה שוב.",
},
+ ended: {
+ badge: "הסתיימה (כלל האוסף)",
+ reason:
+ "{action, select, unlist {הרשומה הזו הסתיימה ב-{time} (שעון {zone}) לפי כלל התפוגה של האוסף שלה על „{field}”. היא עדיין מפורסמת והעמוד שלה נשאר בכתובת שלו, אבל היא יצאה מרשימות, מפידים, מה-sitemap ומהחיפוש.} hide {הרשומה הזו הסתיימה ב-{time} (שעון {zone}) לפי כלל התפוגה של האוסף שלה על „{field}”. היא עדיין מפורסמת, אבל העמוד שלה עונה „לא נמצא”, והיא יצאה מכל רשימה, פיד וחיפוש.} other {הרשומה הזו הסתיימה ב-{time} (שעון {zone}) לפי כלל התפוגה של האוסף שלה על „{field}”.}}",
+ },
structuredData: {
title: "נתונים מובנים",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.hi.ts b/apps/studio/src/i18n/lazy/listings.hi.ts
index 0f122662..3622fe3a 100644
--- a/apps/studio/src/i18n/lazy/listings.hi.ts
+++ b/apps/studio/src/i18n/lazy/listings.hi.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): hi `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: b7f3ca8520cd2e2e
+// hash: cda2025a0d4dc43d
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -50,6 +50,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"इस साइट का लाइव सर्वर अभी समाप्ति लागू नहीं करता। पहले साइट डिप्लॉय करें, फिर समाप्ति दोबारा सहेजें।",
},
+ ended: {
+ badge: "ख़त्म हुई (संग्रह का नियम)",
+ reason:
+ "{action, select, unlist {यह एंट्री अपने संग्रह के “{field}” पर आधारित समाप्ति नियम से {time} ({zone} समय) पर ख़त्म हुई। यह अब भी प्रकाशित है और इसका पेज अपने पते पर है, लेकिन यह सूचियों, फ़ीड, साइटमैप और खोज से हट गई है।} hide {यह एंट्री अपने संग्रह के “{field}” पर आधारित समाप्ति नियम से {time} ({zone} समय) पर ख़त्म हुई। यह अब भी प्रकाशित है, लेकिन इसका पेज “नहीं मिला” जवाब देता है, और यह हर सूची, फ़ीड और खोज से हट गई है।} other {यह एंट्री अपने संग्रह के “{field}” पर आधारित समाप्ति नियम से {time} ({zone} समय) पर ख़त्म हुई।}}",
+ },
structuredData: {
title: "स्ट्रक्चर्ड डेटा",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.id.ts b/apps/studio/src/i18n/lazy/listings.id.ts
index e3f6e7e5..eada2b07 100644
--- a/apps/studio/src/i18n/lazy/listings.id.ts
+++ b/apps/studio/src/i18n/lazy/listings.id.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): id `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 19280fd279b140c4
+// hash: 76038f3da0dc9b93
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -52,6 +52,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"Server live situs ini belum menerapkan kedaluwarsa. Deploy situs terlebih dahulu, lalu simpan kedaluwarsa lagi.",
},
+ ended: {
+ badge: "Berakhir (aturan koleksi)",
+ reason:
+ "{action, select, unlist {Entri ini berakhir pada {time} (waktu {zone}) karena aturan kedaluwarsa koleksinya pada “{field}”. Entri ini masih diterbitkan dan halamannya tetap di alamatnya, tetapi sudah keluar dari daftar, feed, sitemap, dan pencarian.} hide {Entri ini berakhir pada {time} (waktu {zone}) karena aturan kedaluwarsa koleksinya pada “{field}”. Entri ini masih diterbitkan, tetapi halamannya menjawab “tidak ditemukan”, dan sudah keluar dari setiap daftar, feed, dan pencarian.} other {Entri ini berakhir pada {time} (waktu {zone}) karena aturan kedaluwarsa koleksinya pada “{field}”.}}",
+ },
structuredData: {
title: "Data terstruktur",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.it.ts b/apps/studio/src/i18n/lazy/listings.it.ts
index 7fcc4168..3a74a879 100644
--- a/apps/studio/src/i18n/lazy/listings.it.ts
+++ b/apps/studio/src/i18n/lazy/listings.it.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): it `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: c04407e2a6ec168b
+// hash: 6a2e75f60fd32107
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -54,6 +54,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"Il server live di questo sito non applica ancora la scadenza. Distribuisci prima il sito, poi salva di nuovo la scadenza.",
},
+ ended: {
+ badge: "Terminata (regola della raccolta)",
+ reason:
+ "{action, select, unlist {Questa voce è terminata il {time} (ora di {zone}) per la regola di scadenza della sua raccolta su «{field}». È ancora pubblicata e la sua pagina resta al suo indirizzo, ma è uscita da elenchi, feed, sitemap e ricerca.} hide {Questa voce è terminata il {time} (ora di {zone}) per la regola di scadenza della sua raccolta su «{field}». È ancora pubblicata, ma la sua pagina risponde «non trovata» ed è uscita da ogni elenco, feed e ricerca.} other {Questa voce è terminata il {time} (ora di {zone}) per la regola di scadenza della sua raccolta su «{field}».}}",
+ },
structuredData: {
title: "Dati strutturati",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.ja.ts b/apps/studio/src/i18n/lazy/listings.ja.ts
index 7a16971a..f13cb110 100644
--- a/apps/studio/src/i18n/lazy/listings.ja.ts
+++ b/apps/studio/src/i18n/lazy/listings.ja.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ja `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 268f44abed418576
+// hash: 76ad22a72d2435bc
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -51,6 +51,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"このサイトのライブサーバーはまだ有効期限に対応していません。先にサイトをデプロイしてから、もう一度有効期限を保存してください。",
},
+ ended: {
+ badge: "終了(コレクションのルール)",
+ reason:
+ "{action, select, unlist {このエントリーは、コレクションの「{field}」に基づく有効期限ルールにより、{time}({zone}時間)に終了しました。公開済みのままで、ページも同じアドレスに残っていますが、リスト、フィード、サイトマップ、検索からは外れています。} hide {このエントリーは、コレクションの「{field}」に基づく有効期限ルールにより、{time}({zone}時間)に終了しました。公開済みのままですが、ページは「見つかりません」を返し、すべてのリスト、フィード、検索から外れています。} other {このエントリーは、コレクションの「{field}」に基づく有効期限ルールにより、{time}({zone}時間)に終了しました。}}",
+ },
structuredData: {
title: "構造化データ",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.ko.ts b/apps/studio/src/i18n/lazy/listings.ko.ts
index d08b985a..928f9d01 100644
--- a/apps/studio/src/i18n/lazy/listings.ko.ts
+++ b/apps/studio/src/i18n/lazy/listings.ko.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ko `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 7316df5d29a2b7fb
+// hash: 5b83e3d79977cdab
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -50,6 +50,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"이 사이트의 라이브 서버는 아직 만료를 적용하지 않아요. 먼저 사이트를 배포한 다음 만료를 다시 저장하세요.",
},
+ ended: {
+ badge: "종료됨(컬렉션 규칙)",
+ reason:
+ "{action, select, unlist {이 항목은 컬렉션의 “{field}” 만료 규칙에 따라 {time}({zone} 시간)에 종료됐어요. 여전히 게시된 상태이고 페이지도 원래 주소에 남아 있지만, 목록, 피드, 사이트맵, 검색에서는 빠졌어요.} hide {이 항목은 컬렉션의 “{field}” 만료 규칙에 따라 {time}({zone} 시간)에 종료됐어요. 여전히 게시된 상태지만 페이지가 “찾을 수 없음”으로 응답하고, 모든 목록, 피드, 검색에서 빠졌어요.} other {이 항목은 컬렉션의 “{field}” 만료 규칙에 따라 {time}({zone} 시간)에 종료됐어요.}}",
+ },
structuredData: {
title: "구조화된 데이터",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.nl.ts b/apps/studio/src/i18n/lazy/listings.nl.ts
index cdaf33ef..5f4e46c4 100644
--- a/apps/studio/src/i18n/lazy/listings.nl.ts
+++ b/apps/studio/src/i18n/lazy/listings.nl.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): nl `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 9d897fe6ee541f88
+// hash: 6b68f5556160ceaf
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -50,6 +50,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"De live server van deze site past de verloopdatum nog niet toe. Deploy eerst de site en sla de verloopdatum daarna opnieuw op.",
},
+ ended: {
+ badge: "Beëindigd (collectieregel)",
+ reason:
+ "{action, select, unlist {Dit item is op {time} ({zone}-tijd) beëindigd door de verloopregel van zijn collectie op “{field}”. Het is nog gepubliceerd en de pagina blijft op haar adres, maar het is verdwenen uit lijsten, feeds, de sitemap en de zoekfunctie.} hide {Dit item is op {time} ({zone}-tijd) beëindigd door de verloopregel van zijn collectie op “{field}”. Het is nog gepubliceerd, maar de pagina antwoordt met “niet gevonden”, en het is verdwenen uit elke lijst, feed en zoekfunctie.} other {Dit item is op {time} ({zone}-tijd) beëindigd door de verloopregel van zijn collectie op “{field}”.}}",
+ },
structuredData: {
title: "Gestructureerde gegevens",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.pl.ts b/apps/studio/src/i18n/lazy/listings.pl.ts
index 09e020c9..f4b6d52d 100644
--- a/apps/studio/src/i18n/lazy/listings.pl.ts
+++ b/apps/studio/src/i18n/lazy/listings.pl.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): pl `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 256b7cb6e36a6391
+// hash: c3551ca488b1d858
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -50,6 +50,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"Serwer tej witryny na żywo nie stosuje jeszcze wygaśnięcia. Najpierw wdróż witrynę, a potem ponownie zapisz wygaśnięcie.",
},
+ ended: {
+ badge: "Zakończone (reguła kolekcji)",
+ reason:
+ "{action, select, unlist {Ten wpis zakończył się {time} (czas: {zone}) zgodnie z regułą wygasania kolekcji dla pola „{field}”. Nadal jest opublikowany, a jego strona zostaje pod swoim adresem, ale zniknął z list, kanałów, mapy witryny i wyszukiwania.} hide {Ten wpis zakończył się {time} (czas: {zone}) zgodnie z regułą wygasania kolekcji dla pola „{field}”. Nadal jest opublikowany, ale jego strona odpowiada „nie znaleziono” i zniknął ze wszystkich list, kanałów i wyszukiwania.} other {Ten wpis zakończył się {time} (czas: {zone}) zgodnie z regułą wygasania kolekcji dla pola „{field}”.}}",
+ },
structuredData: {
title: "Dane strukturalne",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.pt-BR.ts b/apps/studio/src/i18n/lazy/listings.pt-BR.ts
index 7e293f83..704d6660 100644
--- a/apps/studio/src/i18n/lazy/listings.pt-BR.ts
+++ b/apps/studio/src/i18n/lazy/listings.pt-BR.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): pt-BR `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: fdc20280db78af19
+// hash: 4d9cf006b871e509
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -51,6 +51,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"O servidor no ar deste site ainda não aplica a expiração. Implante o site primeiro e depois salve a expiração de novo.",
},
+ ended: {
+ badge: "Encerrada (regra da coleção)",
+ reason:
+ "{action, select, unlist {Esta entrada foi encerrada em {time} (horário de {zone}) pela regra de expiração da coleção sobre “{field}”. Ela continua publicada e a página continua no mesmo endereço, mas saiu das listas, dos feeds, do sitemap e da busca.} hide {Esta entrada foi encerrada em {time} (horário de {zone}) pela regra de expiração da coleção sobre “{field}”. Ela continua publicada, mas a página responde “não encontrado” e saiu de todas as listas, feeds e da busca.} other {Esta entrada foi encerrada em {time} (horário de {zone}) pela regra de expiração da coleção sobre “{field}”.}}",
+ },
structuredData: {
title: "Dados estruturados",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.ru.ts b/apps/studio/src/i18n/lazy/listings.ru.ts
index a7b4d56a..238da32d 100644
--- a/apps/studio/src/i18n/lazy/listings.ru.ts
+++ b/apps/studio/src/i18n/lazy/listings.ru.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ru `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 212b5b0876a6ad71
+// hash: e58473aefc52dc6e
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -52,6 +52,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"Рабочий сервер этого сайта пока не применяет истечение срока. Сначала разверните сайт, затем снова сохраните настройки истечения срока.",
},
+ ended: {
+ badge: "Завершено (правило коллекции)",
+ reason:
+ "{action, select, unlist {Эта запись завершилась {time} (время: {zone}) по правилу истечения срока её коллекции для поля «{field}». Она по-прежнему опубликована, и её страница остаётся по своему адресу, но она исчезла из списков, лент, карты сайта и поиска.} hide {Эта запись завершилась {time} (время: {zone}) по правилу истечения срока её коллекции для поля «{field}». Она по-прежнему опубликована, но её страница отвечает «не найдено», и она исчезла из всех списков, лент и поиска.} other {Эта запись завершилась {time} (время: {zone}) по правилу истечения срока её коллекции для поля «{field}».}}",
+ },
structuredData: {
title: "Структурированные данные",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.tr.ts b/apps/studio/src/i18n/lazy/listings.tr.ts
index 1e079744..249cd71d 100644
--- a/apps/studio/src/i18n/lazy/listings.tr.ts
+++ b/apps/studio/src/i18n/lazy/listings.tr.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): tr `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 38525c43d447d964
+// hash: 91db7e5c590a6ffe
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -51,6 +51,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"Bu sitenin canlı sunucusu sona ermeyi henüz uygulamıyor. Önce siteyi dağıt, sonra sona ermeyi yeniden kaydet.",
},
+ ended: {
+ badge: "Sona erdi (koleksiyon kuralı)",
+ reason:
+ "{action, select, unlist {Bu kayıt, koleksiyonun “{field}” alanına bağlı sona erme kuralıyla {time} zamanında ({zone} saatiyle) sona erdi. Hâlâ yayında ve sayfası adresinde duruyor, ama listelerden, akışlardan, site haritasından ve aramadan çıktı.} hide {Bu kayıt, koleksiyonun “{field}” alanına bağlı sona erme kuralıyla {time} zamanında ({zone} saatiyle) sona erdi. Hâlâ yayında, ama sayfası “bulunamadı” yanıtı veriyor ve tüm listelerden, akışlardan ve aramadan çıktı.} other {Bu kayıt, koleksiyonun “{field}” alanına bağlı sona erme kuralıyla {time} zamanında ({zone} saatiyle) sona erdi.}}",
+ },
structuredData: {
title: "Yapılandırılmış veri",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.uk.ts b/apps/studio/src/i18n/lazy/listings.uk.ts
index b217632c..150ed58d 100644
--- a/apps/studio/src/i18n/lazy/listings.uk.ts
+++ b/apps/studio/src/i18n/lazy/listings.uk.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): uk `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 0964e008fbfdafb0
+// hash: a29b77b45c04d275
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -51,6 +51,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"Робочий сервер цього сайту ще не застосовує закінчення терміну. Спершу розгорніть сайт, потім знову збережіть налаштування закінчення терміну.",
},
+ ended: {
+ badge: "Завершено (правило колекції)",
+ reason:
+ "{action, select, unlist {Цей запис завершився {time} (час: {zone}) за правилом закінчення терміну його колекції для поля «{field}». Він і далі опублікований, і його сторінка лишається за своєю адресою, але він зник зі списків, стрічок, мапи сайту та пошуку.} hide {Цей запис завершився {time} (час: {zone}) за правилом закінчення терміну його колекції для поля «{field}». Він і далі опублікований, але його сторінка відповідає «не знайдено», і він зник з усіх списків, стрічок і пошуку.} other {Цей запис завершився {time} (час: {zone}) за правилом закінчення терміну його колекції для поля «{field}».}}",
+ },
structuredData: {
title: "Структуровані дані",
description:
diff --git a/apps/studio/src/i18n/lazy/listings.zh-Hans.ts b/apps/studio/src/i18n/lazy/listings.zh-Hans.ts
index 215331b5..18ca5fb0 100644
--- a/apps/studio/src/i18n/lazy/listings.zh-Hans.ts
+++ b/apps/studio/src/i18n/lazy/listings.zh-Hans.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): zh-Hans `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 73abbf89e76c9c11
+// hash: 56540516ff86190e
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"listings"> = {
@@ -46,6 +46,11 @@ export const messages: NamespaceMessages<"listings"> = {
saved: "到期设置已保存",
gatesUnsupported: "此站点的线上服务器尚不执行到期。请先部署站点,然后再次保存到期设置。",
},
+ ended: {
+ badge: "已结束(集合规则)",
+ reason:
+ "{action, select, unlist {此条目已按其集合基于“{field}”的到期规则,于 {time}({zone} 时间)结束。它仍处于已发布状态,页面也仍保留在原地址,但已移出列表、订阅源、站点地图和搜索。} hide {此条目已按其集合基于“{field}”的到期规则,于 {time}({zone} 时间)结束。它仍处于已发布状态,但页面返回“未找到”,并已移出所有列表、订阅源和搜索。} other {此条目已按其集合基于“{field}”的到期规则,于 {time}({zone} 时间)结束。}}",
+ },
structuredData: {
title: "结构化数据",
description:
diff --git a/apps/studio/src/routes/ArticleEditorPage.tsx b/apps/studio/src/routes/ArticleEditorPage.tsx
index 151cb9a3..529352c1 100644
--- a/apps/studio/src/routes/ArticleEditorPage.tsx
+++ b/apps/studio/src/routes/ArticleEditorPage.tsx
@@ -45,13 +45,13 @@ import {
ScheduleLock,
} from "../components/EditorSchedule.tsx";
import { EntryPath } from "../components/EntryPath.tsx";
+import { EntryStatus } from "../components/EntryStatus.tsx";
import { ErrorBanner } from "../components/ErrorBanner.tsx";
import { ViewOnlyBanner } from "../components/Gating.tsx";
import { EntryAddress, ViewLiveButton } from "../components/LiveAddress.tsx";
import { MenuDeleteNotice, useMenuReferences } from "../components/Menus.tsx";
import { RelativeTime } from "../components/RelativeTime.tsx";
import { ArticleSeoPreviewCard, CountedHint, CoverImageCard } from "../components/Seo.tsx";
-import { Status } from "../components/Status.tsx";
import { StatusActions } from "../components/StatusActions.tsx";
import { TagsMigrationBanner } from "../components/TagsMigration.tsx";
import { useTaxonomyData } from "../components/Taxonomy.tsx";
@@ -291,7 +291,7 @@ export function ArticleEditorPage() {
title={title}
description={
-
+
}
diff --git a/apps/studio/src/routes/ArticlesPage.tsx b/apps/studio/src/routes/ArticlesPage.tsx
index 57fb1aae..476b1c6a 100644
--- a/apps/studio/src/routes/ArticlesPage.tsx
+++ b/apps/studio/src/routes/ArticlesPage.tsx
@@ -33,6 +33,7 @@ import { CollectionActions } from "../components/CollectionActions.tsx";
import { SiteCrumbs } from "../components/Crumbs.tsx";
import { deleteResourceProps } from "../components/deleteLabels.ts";
import { EntryPath } from "../components/EntryPath.tsx";
+import { EntryStatus } from "../components/EntryStatus.tsx";
import { ErrorBanner } from "../components/ErrorBanner.tsx";
import { ListToolbar } from "../components/ListToolbar.tsx";
import {
@@ -45,7 +46,6 @@ import { EntryLiveItems } from "../components/LiveRowItems.tsx";
import { MenuDeleteNotice, useMenuReferences } from "../components/Menus.tsx";
import { RelativeTime } from "../components/RelativeTime.tsx";
import { RowMenu } from "../components/RowMenu.tsx";
-import { Status } from "../components/Status.tsx";
import { TableSkeleton } from "../components/TableSkeleton.tsx";
import { TagsMigrationBanner } from "../components/TagsMigration.tsx";
import { type TaxonomyData, useTaxonomyData } from "../components/Taxonomy.tsx";
@@ -536,7 +536,7 @@ function ArticleRow({
)}
-
+
diff --git a/apps/studio/test/ended-collection-rule.test.tsx b/apps/studio/test/ended-collection-rule.test.tsx
new file mode 100644
index 00000000..9da7f7eb
--- /dev/null
+++ b/apps/studio/test/ended-collection-rule.test.tsx
@@ -0,0 +1,388 @@
+/**
+ * RFC-0042 §3.3 in Studio (#113): "Ended (collection rule)" beside the status of a published
+ * entry whose collection's expiry has passed — the pure rule (the entry's own state first, as in
+ * the renderer preview, a published original only), the collection list (a translation's original
+ * from the list) and the entry editor's header (the saved entry over the draft and a stale list,
+ * a translation's original, Turkish).
+ */
+import type { ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts";
+import { TooltipProvider } from "@cloudhal/ui";
+import type { QueryClient } from "@tanstack/react-query";
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { endedByRule, gateZone } from "../src/components/listings/ended-rule.ts";
+import { ArticleEditorPage } from "../src/routes/ArticleEditorPage.tsx";
+import { ArticlesPage } from "../src/routes/ArticlesPage.tsx";
+import { asRole, on, serve, testQueryClient, wrap } from "./roles-fixtures.tsx";
+
+const nav = vi.hoisted(() => ({ entryId: "entry_1", navigate: vi.fn() }));
+vi.mock("@tanstack/react-router", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useParams: () => ({ siteId: "site_1", collection: "events", entryId: nav.entryId }),
+ useNavigate: () => nav.navigate,
+ useSearch: () => ({}),
+}));
+// happy-dom would load the sandboxed preview frame over the network; it is covered elsewhere.
+vi.mock("../src/components/EditorPreview.tsx", () => ({ EditorPreview: () => null }));
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ nav.entryId = "entry_1";
+ nav.navigate.mockReset();
+});
+
+const L = (label: string) => ({ en: { label } });
+const DEFS: FieldDefinition[] = [
+ { key: "startsAt", type: "datetime", labels: L("Starts"), localized: false },
+ { key: "day", type: "date", labels: L("Day"), localized: false },
+];
+
+function siteWith(expiry?: Record, extra: Record = {}): Site {
+ return {
+ schemaVersion: 1,
+ id: "site_1",
+ projectId: "proj_1",
+ name: "Site",
+ slug: "site",
+ defaultLocale: "en",
+ locales: ["en", "tr"],
+ domains: [{ hostname: "example.com", primary: true }],
+ pageIds: [],
+ theme: { name: "blog" },
+ timeZone: "Europe/Istanbul",
+ collections: {
+ events: {
+ labels: { en: { singular: "Event", plural: "Events" } },
+ fields: DEFS,
+ ...(expiry ? { expiry } : {}),
+ },
+ },
+ rev: 7,
+ createdAt: "2026-10-18T10:00:00Z",
+ updatedAt: "2026-10-18T10:00:00Z",
+ ...extra,
+ } as unknown as Site;
+}
+
+const entry = (id: string, fields: Record, extra = {}): ContentEntry =>
+ ({
+ schemaVersion: 1,
+ id,
+ siteId: "site_1",
+ collection: "events",
+ slug: id,
+ locale: "en",
+ status: "published",
+ title: id,
+ body: { format: "markdown", value: "" },
+ fields,
+ tags: [],
+ author: { kind: "human", id: "u_owner" },
+ rev: 1,
+ createdAt: "2026-10-18T10:00:00Z",
+ updatedAt: "2026-10-18T10:00:00Z",
+ ...extra,
+ }) as unknown as ContentEntry;
+
+const PAST = "2020-03-01T10:00:00Z";
+const FUTURE = "2999-01-01T10:00:00Z";
+const starts = (at: string) => ({ startsAt: { at, timeZone: "Europe/Istanbul" } });
+const NOW = Date.parse("2026-10-25T09:00:00Z");
+const UNLIST = { field: "startsAt", action: "unlist" };
+const sameSiteTranslation = (group: string, extra = {}) => ({
+ translation: {
+ group,
+ method: "machine",
+ source: { id: group, locale: "en", rev: 1, hash: "a".repeat(64), ...extra },
+ },
+});
+
+describe("endedByRule", () => {
+ it("ends a published entry once its collection's date has passed, unlist or hide", () => {
+ const e = entry("e", starts(PAST));
+ expect(endedByRule(siteWith(UNLIST), e, undefined, NOW)).toMatchObject({
+ gate: { action: "unlist", at: Date.parse(PAST) },
+ fieldKey: "startsAt",
+ field: { key: "startsAt" },
+ });
+ expect(
+ endedByRule(siteWith({ field: "startsAt", action: "hide" }), e, undefined, NOW)?.gate.action,
+ ).toBe("hide");
+ });
+
+ it("ends exactly at the instant (the end is exclusive) and not a millisecond before", () => {
+ const at = Date.parse(PAST);
+ const e = entry("e", starts(PAST));
+ expect(endedByRule(siteWith(UNLIST), e, undefined, at - 1)).toBeUndefined();
+ expect(endedByRule(siteWith(UNLIST), e, undefined, at)).toBeDefined();
+ });
+
+ it("gives nothing without a rule, a value, a live field or a fitting value", () => {
+ expect(endedByRule(siteWith(), entry("e", starts(PAST)), undefined, NOW)).toBeUndefined();
+ expect(endedByRule(siteWith(UNLIST), entry("e", {}), undefined, NOW)).toBeUndefined();
+ const removing = siteWith(UNLIST);
+ const settings = removing.collections?.events as { fields: FieldDefinition[] };
+ settings.fields = [{ ...(DEFS[0] as FieldDefinition), migration: { id: "snap_01HZX8K3Q9MG" } }];
+ expect(endedByRule(removing, entry("e", starts(PAST)), undefined, NOW)).toBeUndefined();
+ const unfit = entry("e", { startsAt: "2020-03-01" });
+ expect(endedByRule(siteWith(UNLIST), unfit, undefined, NOW)).toBeUndefined();
+ });
+
+ it("lets the status speak first: drafts, reviews and archived entries never show it", () => {
+ for (const status of ["draft", "review", "archived"])
+ expect(
+ endedByRule(siteWith(UNLIST), entry("e", starts(PAST), { status }), undefined, NOW),
+ ).toBeUndefined();
+ });
+
+ it("lets the entry's own availability speak first", () => {
+ const site = siteWith(UNLIST);
+ const own = (availability: Record, at = PAST) =>
+ endedByRule(site, entry("e", starts(at), { availability }), undefined, NOW);
+ // Embargoed: the derived gate never touches `from`.
+ expect(own({ from: "2999-06-01T00:00:00Z" })).toBeUndefined();
+ // Its own "Hide after" closed first (2019 before 2020): the own end speaks.
+ expect(own({ until: "2019-01-01T00:00:00Z" })).toBeUndefined();
+ // Its own end passed, the collection's has not.
+ expect(own({ until: "2019-01-01T00:00:00Z" }, FUTURE)).toBeUndefined();
+ // Its own end is still ahead; the collection's has passed.
+ expect(own({ until: "2999-06-01T00:00:00Z" })?.gate.action).toBe("unlist");
+ });
+
+ it("ends a date at the next midnight in the site's zone, plus the grace", () => {
+ const e = entry("e", { day: "2026-11-05" });
+ const later = Date.parse("2027-01-01T00:00:00Z");
+ expect(
+ endedByRule(siteWith({ field: "day", action: "unlist" }), e, undefined, later)?.gate.at,
+ ).toBe(Date.parse("2026-11-05T21:00:00Z"));
+ expect(
+ endedByRule(
+ siteWith({ field: "day", action: "unlist", afterMinutes: 90 }),
+ e,
+ undefined,
+ later,
+ )?.gate.at,
+ ).toBe(Date.parse("2026-11-05T22:30:00Z"));
+ expect(gateZone({ timeZone: "Europe/Istanbul" })).toBe("Europe/Istanbul");
+ expect(gateZone({ timeZone: "Mars/Olympus" })).toBe("UTC");
+ expect(gateZone({})).toBe("UTC");
+ });
+
+ it("reads a translation's non-localized date from its original, not a language edition's", () => {
+ const original = entry("orig", starts(PAST));
+ const tr = entry("tr_1", {}, { locale: "tr", ...sameSiteTranslation("orig") });
+ expect(endedByRule(siteWith(UNLIST), tr, original, NOW)?.gate.action).toBe("unlist");
+ expect(endedByRule(siteWith(UNLIST), tr, undefined, NOW)).toBeUndefined();
+ const edition = entry(
+ "tr_2",
+ {},
+ { locale: "tr", ...sameSiteTranslation("orig", { siteId: "site_2" }) },
+ );
+ expect(endedByRule(siteWith(UNLIST), edition, original, NOW)).toBeUndefined();
+ });
+
+ it("reads a published original only, as the site does", () => {
+ const tr = entry("tr_1", {}, { locale: "tr", ...sameSiteTranslation("orig") });
+ for (const status of ["draft", "review", "archived"]) {
+ const original = entry("orig", starts(PAST), { status });
+ expect(endedByRule(siteWith(UNLIST), tr, original, NOW)).toBeUndefined();
+ }
+ });
+});
+
+describe("collection list › Ended (collection rule)", () => {
+ const list = (site: Site, entries: ContentEntry[]) => {
+ const fetch = serve(
+ ...asRole("owner"),
+ on("GET", "/api/sites/site_1", { status: 200, body: site }),
+ on("GET", "/api/sites/site_1/entries", { status: 200, body: entries }),
+ );
+ render(wrap());
+ return fetch;
+ };
+ const row = (id: string) => document.querySelector(`tr[data-entry-id="${id}"]`);
+ /** A row that is on screen now (fails rather than reading a missing row). */
+ const shown = (id: string) => {
+ const r = row(id);
+ expect(r).not.toBeNull();
+ return r as HTMLElement;
+ };
+ /** Waits for `id`'s badge; `null` (never `undefined`) while the table has not rendered. */
+ const badgeIn = (id: string, action: "unlist" | "hide") =>
+ waitFor(() => {
+ const b = row(id)?.querySelector(`[data-ended-rule="${action}"]`) ?? null;
+ expect(b).not.toBeNull();
+ return b as HTMLElement;
+ });
+
+ it("marks a published entry past an unlist rule, with the reason in its name", async () => {
+ list(siteWith(UNLIST), [
+ entry("past", starts(PAST)),
+ entry("later", starts(FUTURE)),
+ entry("pastDraft", starts(PAST), { status: "draft" }),
+ ]);
+ const badge = await badgeIn("past", "unlist");
+ expect(shown("past").querySelector('[data-status="published"]')).not.toBeNull();
+ expect(badge).toHaveTextContent("Ended (collection rule)");
+ expect(badge).toHaveTextContent("“Starts”");
+ expect(badge).toHaveTextContent("(Istanbul time)");
+ expect(badge).toHaveTextContent("the sitemap");
+ const button = screen.getByRole("button", { name: /Ended \(collection rule\)/ });
+ button.focus();
+ expect(button).toHaveFocus();
+ expect(shown("later").querySelector("[data-ended-rule]")).toBeNull();
+ expect(shown("pastDraft").querySelector("[data-ended-rule]")).toBeNull();
+ });
+
+ it("words a hide rule as “not found”", async () => {
+ list(siteWith({ field: "startsAt", action: "hide" }), [entry("past", starts(PAST))]);
+ const badge = await badgeIn("past", "hide");
+ expect(badge).toHaveTextContent("“not found”");
+ });
+
+ it("shows nothing when the entry's own end came first, or without a rule", async () => {
+ list(siteWith({ field: "startsAt", action: "unlist" }), [
+ entry("past", starts(PAST)),
+ entry("ownEnd", starts(PAST), { availability: { until: "2019-01-01T00:00:00Z" } }),
+ ]);
+ await badgeIn("past", "unlist");
+ expect(shown("ownEnd").querySelector('[data-status="published"]')).not.toBeNull();
+ expect(shown("ownEnd").querySelector("[data-ended-rule]")).toBeNull();
+ cleanup();
+ vi.unstubAllGlobals();
+ list(siteWith(), [entry("past", starts(PAST)), entry("other", { day: "2020-03-01" })]);
+ // The languages column needs the site, so the site (without a rule) has loaded by then; the
+ // badge's chunk loaded above, so a badge shown regardless of the rule would be here already.
+ await waitFor(() =>
+ expect(row("past")?.querySelector('[data-testid="languages-cell"]') ?? null).not.toBeNull(),
+ );
+ expect(shown("past").querySelector('[data-status="published"]')).not.toBeNull();
+ expect(shown("other")).toBeInTheDocument();
+ expect(document.querySelector("[data-ended-rule]")).toBeNull();
+ });
+
+ it("reads a translation's original from the list it is in, without asking for it", async () => {
+ // One language, so translations are rows of their own rather than grouped.
+ const fetch = list(siteWith(UNLIST, { locales: ["en"] }), [
+ entry("orig", starts(PAST)),
+ entry("tr_1", {}, { locale: "tr", ...sameSiteTranslation("orig") }),
+ entry("archivedOrig", starts(PAST), { status: "archived" }),
+ entry("tr_2", {}, { locale: "tr", ...sameSiteTranslation("archivedOrig") }),
+ ]);
+ await badgeIn("tr_1", "unlist");
+ expect(shown("orig").querySelector('[data-ended-rule="unlist"]')).not.toBeNull();
+ // The site reads published originals only, so this one's translation stays listed.
+ expect(shown("tr_2").querySelector("[data-ended-rule]")).toBeNull();
+ const asked = fetch.mock.calls.map(([u]) => String(u).replace(/^https?:\/\/[^/]+/, ""));
+ expect(asked.filter((u) => /\/entries\/(orig|archivedOrig)(\?|$)/.test(u))).toEqual([]);
+ });
+});
+
+describe("entry editor › Ended (collection rule)", () => {
+ const DAY_RULE = { field: "day", action: "unlist" };
+ const editor = (
+ e: ContentEntry,
+ {
+ extra = [],
+ locale,
+ qc,
+ }: { extra?: Parameters[]; locale?: "tr"; qc?: QueryClient } = {},
+ ) => {
+ nav.entryId = e.id;
+ const fetch = serve(
+ ...asRole("owner"),
+ on("GET", "/api/sites/site_1", { status: 200, body: siteWith(DAY_RULE) }),
+ on("GET", `/api/sites/site_1/entries/${e.id}`, { status: 200, body: e }),
+ ...extra.map((args) => on(...args)),
+ );
+ render(
+ wrap(
+
+
+ ,
+ locale,
+ qc,
+ ),
+ );
+ return fetch;
+ };
+ const badge = () =>
+ waitFor(() => {
+ const b = document.querySelector('[data-ended-rule="unlist"]');
+ expect(b).not.toBeNull();
+ return b as HTMLElement;
+ });
+ const translation = () => entry("tr_1", {}, { locale: "tr", ...sameSiteTranslation("orig_1") });
+
+ it("shows the badge in the header for a saved entry past its date, in the site's zone", async () => {
+ editor(entry("entry_1", { day: "2020-03-01" }));
+ const b = await badge();
+ expect(b.textContent).toMatch(/March 2, 2020 at 12:00\sAM \(Istanbul time\)/);
+ expect(b).toHaveTextContent("“Day”");
+ });
+
+ it("shows nothing before the date", async () => {
+ const qc = testQueryClient();
+ const e = entry("entry_1", { day: "2020-03-01" });
+ editor(e, { qc });
+ // Shown first, so the badge's chunk and words are loaded and it is reading the saved entry.
+ await badge();
+ act(() => {
+ qc.setQueryData(["entry", "site_1", "entry_1"], {
+ ...e,
+ rev: 2,
+ fields: { day: "2999-01-01" },
+ });
+ });
+ await waitFor(() => expect(document.querySelector("[data-ended-rule]")).toBeNull());
+ expect(document.querySelector('[data-status="published"]')).not.toBeNull();
+ });
+
+ it("follows the saved entry, not the unsaved draft", async () => {
+ editor(entry("entry_1", { day: "2020-03-01" }));
+ await badge();
+ const fields = await waitFor(() => {
+ const card = document.getElementById("fields");
+ expect(card).not.toBeNull();
+ return card as HTMLElement;
+ });
+ // Clearing the date changes only the draft: its "Clear" goes, the saved entry still ended.
+ fireEvent.click(await within(fields).findByRole("button", { name: "Clear" }));
+ expect(within(fields).queryByRole("button", { name: "Clear" })).toBeNull();
+ expect(document.querySelector('[data-ended-rule="unlist"]')).not.toBeNull();
+ });
+
+ it("reads a translation's date from its original", async () => {
+ const original = entry("orig_1", { day: "2020-03-01" });
+ const fetch = editor(translation(), {
+ extra: [["GET", "/api/sites/site_1/entries/orig_1", { status: 200, body: original }]],
+ });
+ await badge();
+ expect(
+ fetch.mock.calls.some(([u]) => String(u).endsWith("/api/sites/site_1/entries/orig_1")),
+ ).toBe(true);
+ });
+
+ it("reads the saved original over a list cache that a save left stale", async () => {
+ const qc = testQueryClient();
+ const saved = entry("orig_1", { day: "2020-03-01" }, { rev: 2 });
+ // The list was cached before the original's date was changed and saved; the save invalidated
+ // it (no list on screen refetches it) and wrote the saved original.
+ qc.setQueryData(["entries", "site_1", "events"], [entry("orig_1", { day: "2999-01-01" })]);
+ await qc.invalidateQueries({ queryKey: ["entries", "site_1"] });
+ qc.setQueryData(["entry", "site_1", "orig_1"], saved);
+ editor(translation(), {
+ qc,
+ extra: [["GET", "/api/sites/site_1/entries/orig_1", { status: 200, body: saved }]],
+ });
+ await badge();
+ });
+
+ it("speaks Turkish", async () => {
+ editor(entry("entry_1", { day: "2020-03-01" }), { locale: "tr" });
+ const b = await badge();
+ expect(b).toHaveTextContent("Sona erdi (koleksiyon kuralı)");
+ expect(b).toHaveTextContent("2 Mart 2020 00:00 zamanında (Istanbul saatiyle)");
+ });
+});
diff --git a/apps/studio/test/roles-fixtures.tsx b/apps/studio/test/roles-fixtures.tsx
index 2339adf1..42db7f4a 100644
--- a/apps/studio/test/roles-fixtures.tsx
+++ b/apps/studio/test/roles-fixtures.tsx
@@ -10,8 +10,11 @@ import { I18nProvider, type Locale } from "../src/i18n/index.tsx";
import type { Ability, InstanceInfo, Me, Role } from "../src/lib/api.ts";
import { FALLBACK_ABILITIES, FALLBACK_ROLE_SCOPES, holds, roleRules } from "../src/lib/roles.ts";
-export function wrap(children: ReactNode, locale: Locale = "en") {
- const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+/** A query client as `wrap` makes it, for tests that seed or change its cache. */
+export const testQueryClient = () =>
+ new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+export function wrap(children: ReactNode, locale: Locale = "en", qc = testQueryClient()) {
return (
diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md
index 72e120ba..bcca9fd3 100644
--- a/docs/design/DESIGN.md
+++ b/docs/design/DESIGN.md
@@ -93,7 +93,7 @@ Multilingual sites (`site.locales.length > 1`, an internal review (2026-10) §4a
Snapshots: the primary "Create snapshot" opens a `LayerDialog` (optional label `Input` + `Radio appearance="card"`: keep the live site / publish it); publishing continues into `ApprovalDialog` before anything is sent. `SnapshotTimeline` rows keep Compare (ghost) and Publish/Roll back (secondary) in separate trailing columns so actions align, the last one `sticky="right"`; times via its `renderTime` prop (`RelativeTime`).
### 2b. Resource detail / editor
-`PageLayout` (crumbs via `SiteCrumbs` with `copy={id}`; title; description = status badge + path in mono; actions = `PreviewToggle`, `StatusActions` — a secondary "More actions" ⋯ `DropdownMenu` with a "Status" group plus Duplicate and Delete, and the next lifecycle step as a labelled secondary button, Publish via `ApprovalDialog` — and the primary Save with `loading`, disabled until dirty) → `EditorLayout`. Main: `SectionCard`s with fields in `Grid 2up/3up` (`items-start gap-x-4 gap-y-5`, inputs `w-full`); page sections are one `SectionCard` each (kind icon + name, id as `InlineCopyText`, ghost square icon buttons with tooltips: move up/down, remove → `ConfirmDialog`), added from an "Add section" `DropdownMenu`. Side: `DetailsCard` (`dl` rows: ID, language, revision, updated as `RelativeTime`…) and `DangerZoneCard` (`secondary-destructive` → `DeleteResource`), both in `EditorLayout.tsx`. Lifecycle = explicit actions, never a status `Select`.
+`PageLayout` (crumbs via `SiteCrumbs` with `copy={id}`; title; description = status badge (+ "Ended (collection rule)", §2ah) + path in mono; actions = `PreviewToggle`, `StatusActions` — a secondary "More actions" ⋯ `DropdownMenu` with a "Status" group plus Duplicate and Delete, and the next lifecycle step as a labelled secondary button, Publish via `ApprovalDialog` — and the primary Save with `loading`, disabled until dirty) → `EditorLayout`. Main: `SectionCard`s with fields in `Grid 2up/3up` (`items-start gap-x-4 gap-y-5`, inputs `w-full`); page sections are one `SectionCard` each (kind icon + name, id as `InlineCopyText`, ghost square icon buttons with tooltips: move up/down, remove → `ConfirmDialog`), added from an "Add section" `DropdownMenu`. Side: `DetailsCard` (`dl` rows: ID, language, revision, updated as `RelativeTime`…) and `DangerZoneCard` (`secondary-destructive` → `DeleteResource`), both in `EditorLayout.tsx`. Lifecycle = explicit actions, never a status `Select`.
### 2c. Settings / form
Same header; `Columns variant="1-2"` (a fixed 13 rem sticky `TableOfContents` and cards capped at 56 rem, side by side once the page container is ≥ `@4xl`); one `SectionCard` per section, `Switch` for instant settings, `SensitiveInput` for secrets, `ClipboardText` for ids; sticky footer `border-t border-kumo-line` with `Button variant="primary" type="submit" loading`.
@@ -485,6 +485,7 @@ Words in the lazy `fields` namespace (loaded with the entry editor and collectio
- **Collection settings › Listing fields, Expiry, Structured data** (RFC-0042 §7, ADR-0044; `components/listings/`, words in the lazy `listings` namespace, `LAZY_ONLY` with `lib/listings.ts`, `lib/listing-query.ts`, `lib/api/listings.ts` and `@cloudhal/core-ts/structured-data`): three `SettingsSection`s after Fields, in the table of contents (`#listing-fields`, `#expiry`, `#structured-data`), one lazy chunk with a skeleton section while it loads. Each saves only its own setting (`PUT /collections/:key` with the current settings and that key replaced): footer ghost "Discard" while dirty, primary "Save …" (gated, `title` = reason); without `site:settings` the page's `ReadOnly` fieldset and `readOnlyReason` disable every control. **Listing fields**: up to four chosen fields as hairline rows (label bold, key mono, secondary type `Badge`, outline "Sortable" for number/date/datetime; ghost square up / down / remove), then a `Select` "Add a listing field" (disabled at four, with the reason as its description); empty → `SectionEmpty` (`Columns`); a 409 `list_field_in_use` shows the error `Banner` and links to the pages whose content lists still use the field. **Expiry**: `SwitchField` "End entries on a date", the date field `Select` (date and date-and-time fields only; none → a muted line), the action as `Radio.Group appearance="card"` ("Leave lists" / "Hide the page", each with RFC-0042 §3.3 in words), the grace in minutes (`Input type=number`, 0–10,080), and a live secondary line counting published entries already past it (counted in Studio with `fieldGateInstant`). A 409 `gates_unsupported` becomes an alert `Banner` (`CloudArrowUp`) that says to deploy the site first. **Structured data**: a type `Select` (None = blog post, Event, Job posting, Product, Recipe), then hairline rows of the type's properties — required first (warning `Badge` "Required", outline "One of these" for address groups) — each path in mono with a `Select` of only the sources that fit (its default, fields of a fitting type, entry and site properties, "A fixed value" where allowed; a fixed value on an enum path is a vocabulary `Select`, a currency gets the ISO hint). `checkPreset` runs while editing and the refused save's `details.issues` join it, worded per code under their row (`Text variant="error"`). Muted lines suggest "Needed to publish" for fields behind required properties and remind that the theme should show mapped fields. With a saved preset, an `InsetPanel` previews a chosen entry (`GET …/entries/:id/structured-data`): issues, the JSON-LD in `CodeHighlighted`, and for a published entry on a domain a secondary `LinkButton` to Google's Rich Results Test.
- **Create listing page** (RFC-0042 §5): a secondary header action on collection settings (`pages.write` only) that creates a draft page at the collection's prefix titled with its plural, holding one `contentList` (upcoming first, soonest first, when the collection has a date listing field and an expiry), then opens it in the page editor. When a page already lives there it becomes "Open listing page" (`LinkButton`, the address in its `title`); a refused write is an error toast.
- **Collection list › listing fields** (RFC-0042 §4): a collection with `listFields` shows them as columns after the title (values through `formatFieldValue` in the Studio language, "—" when empty), a `Toolbar` of `Select`s after the facets — one per field with type-fitting choices (dates: upcoming / past / has a value / empty; yes / no; each option of a choice; has a value / empty) — and ghost sort buttons in number and date headers (ascending → descending → off, `aria-sort`). Filters and sort live in the URL (`f_=`, `sort=:asc|desc`, `lib/listing-search.ts`); while any is set, the list asks `GET …/entries` with `where`/`orderBy`/`limit`/`cursor` one page at a time (`Pagination controls="simple"`), "Clear filters" clears facets and field filters together. The columns, filters and sort are a lazy chunk.
+- **Collection list and entry editor › Ended (collection rule)** (RFC-0042 §3.3, #113): a published entry whose collection's expiry has passed gets a `ToggletipBadge` right after its `Status` badge — in the list's status cell and in the editor header's description (`components/EntryStatus.tsx`, `inline-flex flex-wrap items-center gap-1.5`; the list cell stays `whitespace-nowrap`). `secondary` with a 14 px `CalendarX` (§2x: the expiry icon, the quiet tone of ended states — a configured outcome, never warning or error); one tone and icon for both actions; the toggletip's words tell them apart. The toggletip (a button that opens on hover, keyboard focus and a press, so a tap opens it on touch screens, where a tooltip alone never opens; the reason is also in its accessible name) names the date field (`fieldLabel` in the Studio language, else the rule's key), the instant in the site's zone (`formatDate` long date + short time, the zone's city from `zoneCity`, UTC when the site's zone is unknown; the instant already includes the grace and, for a date, the next midnight) and what the site does: `unlist` keeps the address but leaves lists, feeds, the sitemap and search; `hide` answers "not found" and leaves every list, feed and search — both say the entry is still published, which is why a Published badge sits beside it. It shows only when the entry is published, its own `availability` is open and the derived gate has passed (`endedByRule`: `derivedGate` + `entryVisibility`, the end exclusive); drafts, embargoed entries and entries whose own "Hide after" passed get no badge, as in the renderer preview (their own dates show in the editor's Schedule card). It reads the saved site settings and the saved entry (the editor passes `query.data`, not the draft, so a half-typed date does not flip it); a translation reads its original's non-localized date only while the original is published, as the site does (`derivedGates(site, published)`), from the newer of the list's cached entries (while not invalidated) and the shared `["entry", siteId, group]` query that a save writes; `useNow(60_000)` flips it within a minute. It describes the saved working state: the live site applies a changed rule or value only with the next publish (RFC-0042 §3.4). Only a collection with an expiry loads the badge (`components/listings/EndedBadge.tsx`, `ended-rule.ts`) and its `listings` words, through `lazy(() => withMessages(import(…), ["listings"]))`; the wrapper carries `data-ended-rule="unlist|hide"` for tests.
- **Page editor › Content list** (RFC-0042 §4): under collection / limit / sort, a lazy "Only entries where" block over the listed collection's listing fields — rows of field, condition (only that type's operators, worded "is before / after" for dates), value (now or a date for dates, the options for a choice, a `Checkbox.Group` for "is one of", yes / no) and a ghost square remove; secondary "Add condition" (up to four); "Order by field" (number and date fields) with its direction. A save refused with `invalid_query` shows each issue under its row. A collection without listing fields shows a muted line with a link to its settings.
- **Approvals and Activity**: `rename_field`, `change_field_type`, `set_field_localized`, `remove_field`, `list_collections`, `create_collection`, `update_collection` and `preview_structured_data` have labels in `tools.*`; `change_field_type` and `remove_field` are `CONFIRM_TOOLS` (the server's dry-run prompt shows, and approving asks first). Field and RFC-0042 error codes (`invalid_query`, `structured_data_invalid`, `invalid_expiry`, `list_field_in_use`, `gates_unsupported`) read through `apiErrorText` (`errors.api.*`); a 409 whose code has its own words shows them, not "someone else changed this". An `auto`-mode publish held back by a new or earlier collection expiry gives the reason `approvals.autoCheck.expires`.
@@ -610,6 +611,7 @@ Studio must use each component where it fits; "later" means the surface does not
| ScheduleItem / ScheduleStatusBadge / ScheduleFilters / TimezoneNote (Cloudhal blocks) | one scheduled thing as a chip or row in the schedules visual language (proposals dashed, D14), every release/automation/run/approval state as an icon + word badge, the calendar's multi-value filters and views, and the site-zone note with "Show in my time" (§2x) |
| DateTimeField / RecurrenceEditor / NextRuns / RescheduleDialog / ScheduleRunRow (Cloudhal blocks) | date + 24-hour time in the site zone (the lazy `DatePicker` and a typed time), recurrence presets and the RRULE subset, the server's next runs with clock-change badges, "Move to…" with the approval kept or voided, and the run history row with lateness, budget use and the taint marker (§2x) |
| EntryFieldsForm (Cloudhal block) | an entry's typed custom fields (RFC-0041): one Kumo control per field type from the collection's definitions — `Input`/`InputArea`, `Switch`, `DateTimeField` (date only, or date and time in the value's zone), `Select`/`Checkbox.Group` — labels and help from the definitions in the Studio language, the "Needed to publish" badge, errors per field, and translation fallbacks (shared fields read-only with a link to the original, the original's value as placeholder); rich text and images come from the host (`renderRichText`, `renderImage`). Every word is a prop (§2ah) |
+| ToggletipBadge (Cloudhal block) | a badge that explains itself: a Kumo `Badge` (variant, 14 px icon) inside a button that opens its `reason` as a `Tooltip` on hover, keyboard focus and a press (a press keeps it open until a second press, Escape or a press elsewhere), wrapped at 18 rem (`max-w-72`), the reason repeated for screen readers in the accessible name; every word a prop — "Ended (collection rule)" in the collection list and entry editor (§2ah) |
| Switch | instant settings; browser notifications (Account › Notifications); page "Hide from search engines" (§2o) |
| Table | all lists |
| TableOfContents | settings sections (`SettingsLayout`, driven by `useTableOfContentsActiveId`) |
diff --git a/packages/ui/src/blocks/ToggletipBadge.tsx b/packages/ui/src/blocks/ToggletipBadge.tsx
new file mode 100644
index 00000000..9a3a2b4c
--- /dev/null
+++ b/packages/ui/src/blocks/ToggletipBadge.tsx
@@ -0,0 +1,73 @@
+import { Badge, type BadgeVariant, cn, Tooltip } from "@cloudflare/kumo";
+import { type ReactNode, useRef, useState } from "react";
+
+export interface ToggletipBadgeProps {
+ /** The badge's word, from the screen's catalog. */
+ label: string;
+ /** What the badge means, shown in the toggletip and read as part of the badge's name. */
+ reason: string;
+ /** The badge's tone (Kumo `Badge` variant); `secondary` for quiet states. */
+ variant?: BadgeVariant;
+ /** A 14 px Phosphor icon with `aria-hidden`; the word always says the state too. */
+ icon?: ReactNode;
+ className?: string;
+}
+
+/** How Kumo's `Tooltip` (Base UI) says why it wants to open or close. */
+type OpenChange = { reason?: string } | undefined;
+
+/**
+ * A badge that explains itself: a Kumo `Badge` inside a button whose `reason` opens in a Kumo
+ * `Tooltip` on hover, on keyboard focus and on a press — Base UI tooltips never open on touch, so
+ * the button toggles it (open, then closed by a second press, Escape or a press elsewhere; a
+ * pressed-open toggletip stays when the pointer leaves). The reason wraps at 18 rem, and is repeated
+ * for screen readers inside the badge, so it is part of the button's accessible name
+ * ("Ended (collection rule). This entry…"). Every word is a prop; there is no fallback.
+ */
+export function ToggletipBadge({
+ label,
+ reason,
+ variant = "secondary",
+ icon,
+ className,
+}: ToggletipBadgeProps) {
+ const [open, setOpen] = useState(false);
+ /** Opened by a press: hover and focus no longer close it, only a press, Escape or outside. */
+ const pinned = useRef(false);
+ const onOpenChange = (next: boolean, details: OpenChange) => {
+ // The trigger's own press is handled by `onClick` (the tooltip would only ever close on it).
+ if (details?.reason === "trigger-press") return;
+ if (!next && pinned.current && details?.reason === "trigger-hover") return;
+ if (!next) pinned.current = false;
+ setOpen(next);
+ };
+ return (
+ {reason}}
+ // A render function, so our classes are merged with the tooltip trigger's, not replaced.
+ render={(props) => (
+
+ );
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 38d5ccb4..e25c8c64 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -497,6 +497,7 @@ export {
TimezoneNote,
type TimezoneNoteProps,
} from "./blocks/TimezoneNote.tsx";
+export { ToggletipBadge, type ToggletipBadgeProps } from "./blocks/ToggletipBadge.tsx";
export {
type ChartColors,
mixHex,
diff --git a/packages/ui/test/blocks.test.tsx b/packages/ui/test/blocks.test.tsx
index c82006e9..61268e23 100644
--- a/packages/ui/test/blocks.test.tsx
+++ b/packages/ui/test/blocks.test.tsx
@@ -1,5 +1,5 @@
import type { Snapshot } from "@cloudhal/core-contracts";
-import { fireEvent, render, screen, within } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
ActorBadge,
@@ -11,6 +11,7 @@ import {
SnapshotTimeline,
type SnapshotTimelineLabels,
StatusBadge,
+ ToggletipBadge,
} from "../src/index.ts";
const labels: SnapshotTimelineLabels = {
@@ -52,6 +53,39 @@ describe("StatusBadge", () => {
});
});
+describe("ToggletipBadge", () => {
+ it("is a keyboard toggletip whose name carries the reason", () => {
+ render(
+ }
+ />,
+ );
+ const button = screen.getByRole("button", { name: /Label\s?\.\s?Reason/ });
+ expect(button).toHaveAttribute("type", "button");
+ expect(button).toHaveAttribute("data-base-ui-tooltip-trigger");
+ expect(within(button).getByTestId("badge-icon")).toBeInTheDocument();
+ });
+
+ it("opens its reason on a press too (tooltips never open on touch) and closes on the next", async () => {
+ // This file renders without cleanup between tests, so the words are this test's own.
+ render();
+ const button = screen.getByRole("button", { name: /Pressed/ });
+ // Only the screen-reader copy, never a bare reason, before the press.
+ expect(screen.queryByText("Why it is pressed")).toBeNull();
+ fireEvent.click(button);
+ const shown = await screen.findByText("Why it is pressed");
+ expect(button).not.toContainElement(shown);
+ expect(shown).toHaveClass("max-w-72");
+ // Leaving with the pointer keeps a pressed-open toggletip; a second press closes it.
+ fireEvent.mouseLeave(button);
+ expect(screen.getByText("Why it is pressed")).toBeInTheDocument();
+ fireEvent.click(button);
+ await waitFor(() => expect(screen.queryByText("Why it is pressed")).toBeNull());
+ });
+});
+
describe("SnapshotTimeline", () => {
it("offers Publish for newer snapshots and Roll back for older ones", () => {
const onPublish = vi.fn();