+ ))
+ }
+
diff --git a/apps/site/src/components/blocks/status.ts b/apps/site/src/components/blocks/status.ts
new file mode 100644
index 00000000..9fe03abd
--- /dev/null
+++ b/apps/site/src/components/blocks/status.ts
@@ -0,0 +1,10 @@
+/** What a card's status badge says, also used for the legend above a cards section */
+export const statusLabels = { required: "Required", recommended: "Recommended", "not-needed": "Not needed" } as const;
+
+export const badgeClass = {
+ required: "bg-ink text-paper border-ink",
+ recommended: "border-journal text-journal",
+ "not-needed": "border-dashed border-muted text-muted",
+} as const;
+
+export const badge = "self-start border-[1.5px] px-2 py-0.5 text-xs font-bold tracking-wide uppercase";
diff --git a/apps/site/src/components/blocks/switch.ts b/apps/site/src/components/blocks/switch.ts
new file mode 100644
index 00000000..8fb87e0b
--- /dev/null
+++ b/apps/site/src/components/blocks/switch.ts
@@ -0,0 +1,51 @@
+/*
+ * Show one panel of a group at a time: the chooser's options and the routes
+ * of a steps section. In the HTML, a group is
+ *
+ *
+ *
+ *
…
+ *
+ * Without JavaScript every panel shows. A link to a panel's id
+ * (/journal/for-authors#registered-report) opens that panel.
+ */
+
+function select(group: Element, id: string, scroll = false) {
+ for (const button of group.querySelectorAll("[data-switch-to]")) {
+ if (button.closest("[data-switch]") !== group) continue;
+ button.setAttribute("aria-pressed", String(button.dataset.switchTo === id));
+ }
+ let shown: HTMLElement | undefined;
+ for (const panel of group.querySelectorAll("[data-switch-panel]")) {
+ if (panel.closest("[data-switch]") !== group) continue;
+ panel.hidden = panel.id !== id;
+ if (!panel.hidden) shown = panel;
+ }
+ // On narrow screens the panel sits below the cards, out of sight
+ if (scroll && shown && shown.getBoundingClientRect().top > window.innerHeight * 0.6) {
+ const smooth = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ shown.scrollIntoView({ behavior: smooth ? "smooth" : "auto", block: "start" });
+ }
+}
+
+function openFromHash() {
+ const id = decodeURIComponent(location.hash.slice(1));
+ const panel = id && document.getElementById(id);
+ const group = panel instanceof HTMLElement && panel.matches("[data-switch-panel]") ? panel.closest("[data-switch]") : null;
+ if (!group || !panel) return;
+ select(group, id);
+ panel.scrollIntoView({ block: "start" });
+}
+
+document.addEventListener("click", (event) => {
+ const button = (event.target as Element).closest("[data-switch-to]");
+ const group = button?.closest("[data-switch]");
+ if (!button || !group) return;
+ const id = button.dataset.switchTo!;
+ select(group, id, true);
+ // So the address can be shared, without jumping
+ history.replaceState(null, "", `#${id}`);
+});
+
+window.addEventListener("hashchange", openFromHash);
+openFromHash();
diff --git a/apps/site/src/components/sections/CardsSection.astro b/apps/site/src/components/sections/CardsSection.astro
new file mode 100644
index 00000000..87ec5cf3
--- /dev/null
+++ b/apps/site/src/components/sections/CardsSection.astro
@@ -0,0 +1,26 @@
+---
+import type { Section } from "~/content.config";
+import Cards from "../blocks/Cards.astro";
+import { badge, badgeClass, statusLabels } from "../blocks/status";
+import SectionHeading from "./SectionHeading.astro";
+
+type Props = Extract;
+const { id, eyebrow, title, intro, legend, columns, items } = Astro.props;
+const used = (Object.keys(statusLabels) as (keyof typeof statusLabels)[]).filter((s) => items.some((card) => card.status === s));
+---
+
+
+
+ {
+ legend && used.length > 0 && (
+
diff --git a/apps/site/src/content.config.ts b/apps/site/src/content.config.ts
index c5eb6a13..791d3b5f 100644
--- a/apps/site/src/content.config.ts
+++ b/apps/site/src/content.config.ts
@@ -5,7 +5,9 @@ import { contentDir } from "./integrations/content";
/*
* Pages built from sections. Each file in pages/ of site-content is one page:
- * index.yaml is the homepage, about.yaml is /about, and so on. A page is a
+ * index.yaml is the homepage, about.yaml is /about, and so on. Files in
+ * pages/journal/ are the journal's own pages: pages/journal/for-authors.yaml
+ * is /journal/for-authors, under the journal's menu. A page is a
* list of sections, drawn top to bottom. Each section type is a component in
* src/components/sections. Text fields accept Markdown.
*/
@@ -15,6 +17,88 @@ const background = z.enum(["paper", "accent", "accent-soft", "journal", "journal
/** How a list of journal articles looks: cards with the abstract, or a compact list like a table of contents */
export const articleStyle = z.enum(["cards", "list"]);
+/*
+ * Building blocks for structured pages, like the journal's For Authors page.
+ * Cards and steps are sections of their own, and also go inside the panel
+ * of a chooser option, with text, numbered parts and "read more" boxes.
+ */
+
+/** Required / recommended / not needed: sets a card's badge and frame */
+const status = z.enum(["required", "recommended", "not-needed"]);
+
+const card = z.object({
+ title: z.string(),
+ body: z.string().optional(),
+ status: status.optional(),
+ /** Badge text instead of the status's own ("Required if 2+ authors") */
+ badge: z.string().optional(),
+ /** box: framed; headed: title in a dark bar; soft: tinted; dashed; rule: a line on top; plain: nothing */
+ look: z.enum(["box", "headed", "soft", "dashed", "rule", "plain"]).default("box"),
+ /** Small boxed words under the text (CRediT roles) */
+ /** Markdown, so a tag can be a link: [Methodology](https://…) */
+ tags: z.array(z.string()).default([]),
+ /** Extra text that opens on click */
+ more: z.object({ label: z.string(), body: z.string() }).optional(),
+});
+
+const step = z.object({
+ /** Small text above the title ("Stage 1 · You"). Steps without one are numbered */
+ kicker: z.string().optional(),
+ title: z.string(),
+ text: z.string().optional(),
+ /** Draw this step in the journal colour, for a milestone */
+ highlight: z.boolean().default(false),
+});
+
+const block = z.discriminatedUnion("type", [
+ z.object({ type: z.literal("text"), body: z.string() }),
+ /** Numbered rows, such as the parts of a manuscript in order */
+ z.object({
+ type: z.literal("parts"),
+ items: z.array(z.object({ title: z.string(), text: z.string().default("") })),
+ }),
+ z.object({
+ type: z.literal("cards"),
+ /** Most cards side by side */
+ columns: z.number().int().min(1).max(4).optional(),
+ items: z.array(card),
+ }),
+ z.object({ type: z.literal("steps"), steps: z.array(step) }),
+ z.object({ type: z.literal("more"), label: z.string(), body: z.string() }),
+]);
+
+export type Card = z.infer;
+export type Step = z.infer;
+export type Block = z.infer;
+
+/** The top of every structured section: "Step 1", the title, a line of text */
+const heading = {
+ id: z.string().optional(),
+ eyebrow: z.string().optional(),
+ title: z.string().optional(),
+ intro: z.string().optional(),
+ /** Shorter name for the "On this page" list */
+ short: z.string().optional(),
+};
+
+const option = z.object({
+ /** For links straight to this option (/journal/for-authors#registered-report). Made from the title if empty */
+ id: z.string().optional(),
+ title: z.string(),
+ badge: z.string().optional(),
+ summary: z.string().optional(),
+ facts: z.array(z.object({ label: z.string(), value: z.string() })).default([]),
+ /** The panel's title ("Empirical article: what to include"). The option's title if empty */
+ heading: z.string().optional(),
+ /** Small text beside the panel's title */
+ note: z.string().optional(),
+ /** journal: the chosen card and the panel's title bar are in the journal colour instead of black */
+ tone: z.enum(["ink", "journal"]).default("ink"),
+ blocks: z.array(block).default([]),
+});
+
+export type Option = z.infer;
+
const section = z.discriminatedUnion("type", [
z.object({
type: z.literal("hero"),
@@ -30,6 +114,7 @@ const section = z.discriminatedUnion("type", [
title: z.string().optional(),
body: z.string(),
background,
+ buttons: z.array(button).default([]),
}),
z.object({
type: z.literal("features"),
@@ -71,6 +156,39 @@ const section = z.discriminatedUnion("type", [
type: z.literal("buttons"),
buttons: z.array(button),
}),
+ /** "On this page": links to the sections below it that have an anchor and a title */
+ z.object({
+ type: z.literal("contents"),
+ title: z.string().default("On this page"),
+ }),
+ /** Cards to pick from, each opening its own panel ("What are you submitting?") */
+ z.object({
+ type: z.literal("chooser"),
+ ...heading,
+ groups: z.array(
+ z.object({
+ title: z.string().optional(),
+ note: z.string().optional(),
+ options: z.array(option),
+ }),
+ ),
+ }),
+ z.object({
+ type: z.literal("cards"),
+ ...heading,
+ /** Explain the Required / Recommended / Not needed badges above the cards */
+ legend: z.boolean().default(false),
+ /** Most cards side by side */
+ columns: z.number().int().min(1).max(4).optional(),
+ items: z.array(card),
+ }),
+ /** A numbered process. With more than one route, the routes become tabs */
+ z.object({
+ type: z.literal("steps"),
+ ...heading,
+ routes: z.array(z.object({ label: z.string().default(""), steps: z.array(step) })),
+ note: z.string().optional(),
+ }),
]);
export type Section = z.infer;
diff --git a/apps/site/src/layouts/SectionsPage.astro b/apps/site/src/layouts/SectionsPage.astro
index e7bab05f..36a0e514 100644
--- a/apps/site/src/layouts/SectionsPage.astro
+++ b/apps/site/src/layouts/SectionsPage.astro
@@ -13,20 +13,34 @@ interface Props {
searchSection?: string;
}
-/* A page built from sections in site-content: pages/.yaml, also drawn by /admin's preview */
+/* A page built from sections in site-content: pages/.yaml, or pages/journal/.yaml for the journal. Also drawn by /admin's preview */
const { page, journal, searchSection } = Astro.props;
+// A journal page's tab names the journal too, as its PubPub pages do
+const title = journal && page.title !== journal.config.title ? `${page.title} | ${journal.config.title}` : page.title;
---
-
+
{journal && }
{
- page.showTitle && (
-
-
-
{page.title}
+ page.showTitle &&
+ (journal ? (
+ // Like the journal's PubPub pages (JournalPage.astro): the journal's name, the title and the description
+