diff --git a/apps/self-hosted/hosting/api/src/style-template-display.ts b/apps/self-hosted/hosting/api/src/style-template-display.ts index 2d84654ffc..4e82d533ee 100644 --- a/apps/self-hosted/hosting/api/src/style-template-display.ts +++ b/apps/self-hosted/hosting/api/src/style-template-display.ts @@ -81,6 +81,17 @@ export const STYLE_TEMPLATE_DISPLAY = { }, headingStyle: 'sans', }, + journal: { + name: 'Journal', + tagline: 'Ink on paper: one quiet column for long-form writing', + colors: { + background: '#faf8f4', + surface: '#f2efe8', + accent: '#9c4a1e', + text: '#221d17', + }, + headingStyle: 'serif', + }, } satisfies Record; export function templateCatalog() { diff --git a/apps/self-hosted/hosting/api/src/style-templates.ts b/apps/self-hosted/hosting/api/src/style-templates.ts index a287475afe..c22087a573 100644 --- a/apps/self-hosted/hosting/api/src/style-templates.ts +++ b/apps/self-hosted/hosting/api/src/style-templates.ts @@ -23,6 +23,7 @@ export const STYLE_TEMPLATES = Object.freeze([ 'magazine', 'developer', 'modern-gradient', + 'journal', ] as const); export type StyleTemplate = (typeof STYLE_TEMPLATES)[number]; diff --git a/apps/self-hosted/src/core/i18n-strings.ts b/apps/self-hosted/src/core/i18n-strings.ts index b18b4a7e4b..cf069624ed 100644 --- a/apps/self-hosted/src/core/i18n-strings.ts +++ b/apps/self-hosted/src/core/i18n-strings.ts @@ -258,6 +258,7 @@ export type TranslationKey = | 'panel_configuration_general_style_template_magazine_option' | 'panel_configuration_general_style_template_developer_option' | 'panel_configuration_general_style_template_modern_gradient_option' + | 'panel_configuration_general_style_template_journal_option' | 'panel_configuration_general_language_label' | 'panel_configuration_general_language_description' | 'panel_configuration_general_language_en_option' @@ -590,6 +591,8 @@ export const translations: { en: Translations } & Record< panel_configuration_general_style_template_magazine_option: 'Magazine (Editorial)', panel_configuration_general_style_template_developer_option: 'Developer (Tech)', panel_configuration_general_style_template_modern_gradient_option: 'Modern Gradient', + panel_configuration_general_style_template_journal_option: + 'Journal (single column, serif, no sidebar)', panel_configuration_general_language_label: 'Language', panel_configuration_general_language_description: 'Default language', panel_configuration_general_language_en_option: 'English', diff --git a/apps/self-hosted/src/features/blog/components/search-results.tsx b/apps/self-hosted/src/features/blog/components/search-results.tsx index be86621a99..ac01e25a70 100644 --- a/apps/self-hosted/src/features/blog/components/search-results.tsx +++ b/apps/self-hosted/src/features/blog/components/search-results.tsx @@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { t } from '@/core'; import { useInstanceConfig } from '../hooks/use-instance-config'; -import { BlogPostItem } from './blog-post-item'; +import { useThemeComponents } from '@/themes/use-theme-components'; interface Props { query: string; @@ -35,6 +35,9 @@ function searchResultToEntry(result: SearchResult): Entry { } export function SearchResults({ query }: Props) { + // The entry card resolves through the theme registry, so search results + // wear the active theme's entry look instead of the default card. + const { PostCard } = useThemeComponents(); const { username, communityId, isCommunityMode } = useInstanceConfig(); // Build search query scoped to the blog/community @@ -92,7 +95,7 @@ export function SearchResults({ query }: Props) {
{results.map((result, index) => ( - { + const defaultFilter = availableFilters[0] || 'posts'; + if (typeof location.search === 'string') { + return ( + new URLSearchParams(location.search).get('filter') || defaultFilter + ); + } + if ( + location.search && + typeof location.search === 'object' && + 'filter' in location.search + ) { + return (location.search.filter as string) || defaultFilter; + } + return defaultFilter; + }, [location.search, availableFilters]); + + // An i18n key when one exists, a capitalized filter name otherwise. + const filterLabel = (filter: string): string => { + const key = `blog.navigation.${filter}`; + const translated = t(key as Parameters[0]); + return translated === key + ? filter.charAt(0).toUpperCase() + filter.slice(1) + : translated; + }; + + return { availableFilters, currentFilter, filterLabel }; +} diff --git a/apps/self-hosted/src/features/blog/layout/blog-navigation.tsx b/apps/self-hosted/src/features/blog/layout/blog-navigation.tsx index 5fd4912bb2..0c49e75da7 100644 --- a/apps/self-hosted/src/features/blog/layout/blog-navigation.tsx +++ b/apps/self-hosted/src/features/blog/layout/blog-navigation.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Link, useLocation } from '@tanstack/react-router'; +import { Link } from '@tanstack/react-router'; import clsx from 'clsx'; import { useMemo } from 'react'; import { UilRss } from '@tooni/iconscout-unicons-react'; @@ -8,36 +8,18 @@ import { InstanceConfigManager, t } from '@/core'; import { getRssFeedUrl } from '@/utils/rss-feed-url'; import { UserMenu, CreatePostButton } from '@/features/auth'; import { useInstanceConfig, useCommunityData } from '../hooks/use-instance-config'; -import { getConfiguredPostsFilters } from '../utils/post-filters'; +import { usePostsFilterState } from '../hooks/use-posts-filter-state'; import { SearchInput } from '../components/search-input'; export function BlogNavigation() { - const location = useLocation(); const { isCommunityMode } = useInstanceConfig(); const { data: community } = useCommunityData(); - // getConfiguredPostsFilters validates the shape. Reading the raw value here - // would re-open the crash it exists to prevent: a scalar is truthy, so - // availableFilters.map below would throw and take the whole layout with it. - const availableFilters = getConfiguredPostsFilters(); - - const currentFilter = useMemo(() => { - // Default to the first configured filter - const defaultFilter = availableFilters[0] || 'posts'; - - if (typeof location.search === 'string') { - const searchParams = new URLSearchParams(location.search); - return searchParams.get('filter') || defaultFilter; - } - if ( - location.search && - typeof location.search === 'object' && - 'filter' in location.search - ) { - return (location.search.filter as string) || defaultFilter; - } - return defaultFilter; - }, [location.search, availableFilters]); + // Shared with theme shells (use-posts-filter-state) so no shell can drift + // from this navigation's filter behavior. The hook validates the configured + // shape, which keeps the scalar-postsFilters crash guard. + const { availableFilters, currentFilter, filterLabel: getFilterLabel } = + usePostsFilterState(); const blogTitle = InstanceConfigManager.useConfig( ({ configuration }) => configuration.instanceConfiguration.meta.title, @@ -63,18 +45,6 @@ export function BlogNavigation() { return null; }, [blogLogo, isCommunityMode, community?.name, proxyBase]); - // Get localized filter label - const getFilterLabel = (filter: string): string => { - // Try i18n key first (e.g., blog.navigation.blog, blog.navigation.trending) - const i18nKey = `blog.navigation.${filter}`; - const translated = t(i18nKey as Parameters[0]); - // If translation returns the key itself, use capitalized filter name - if (translated === i18nKey) { - return filter.charAt(0).toUpperCase() + filter.slice(1); - } - return translated; - }; - return (
diff --git a/apps/self-hosted/src/features/floating-menu/config-fields.test.ts b/apps/self-hosted/src/features/floating-menu/config-fields.test.ts index 3954fa8e62..a6af5cc0ad 100644 --- a/apps/self-hosted/src/features/floating-menu/config-fields.test.ts +++ b/apps/self-hosted/src/features/floating-menu/config-fields.test.ts @@ -863,3 +863,44 @@ describe('isFieldVisible (the visibleWhen capability)', () => { expect(isFieldVisible(broken, doc)).toBe(true); }); }); + +describe('theme-gated layout options', () => { + const fields = buildConfigFields((key) => key); + // Typed walk down the section tree; a missing level fails the suite loudly + // instead of hiding behind a cast. + function sectionFields(field: ConfigField | undefined, name: string) { + if (!field?.fields) throw new Error(`section ${name} has no fields`); + return field.fields; + } + const layout = sectionFields( + sectionFields( + sectionFields(fields.configuration, 'configuration').instanceConfiguration, + 'instanceConfiguration', + ).layout, + 'layout', + ); + + function docWithTemplate(styleTemplate?: string) { + return { + configuration: { general: styleTemplate ? { styleTemplate } : {} }, + } as unknown as Record; + } + + it('hides the sidebar section and list type under a theme that declares them unsupported', () => { + expect(isFieldVisible(layout.sidebar, docWithTemplate('journal'))).toBe(false); + expect(isFieldVisible(layout.listType, docWithTemplate('journal'))).toBe(false); + }); + + it('shows them for every CSS-only template and for an unset template', () => { + for (const template of ['medium', 'minimal', 'magazine', 'developer', 'modern-gradient']) { + expect(isFieldVisible(layout.sidebar, docWithTemplate(template))).toBe(true); + } + expect(isFieldVisible(layout.listType, docWithTemplate())).toBe(true); + }); + + it('follows the UNSAVED draft, so switching templates in the panel reacts immediately', () => { + // The predicate reads the edited document, not the applied config. + expect(isFieldVisible(layout.sidebar, docWithTemplate('journal'))).toBe(false); + expect(isFieldVisible(layout.sidebar, docWithTemplate('medium'))).toBe(true); + }); +}); diff --git a/apps/self-hosted/src/features/floating-menu/config-fields.ts b/apps/self-hosted/src/features/floating-menu/config-fields.ts index 985f7763ce..abb1ae2832 100644 --- a/apps/self-hosted/src/features/floating-menu/config-fields.ts +++ b/apps/self-hosted/src/features/floating-menu/config-fields.ts @@ -17,6 +17,20 @@ import { STYLE_TEMPLATES, type StyleTemplate, } from '../../../hosting/api/src/style-templates'; +import { isThemeOptionSupported } from '@/themes/registry'; + +/** + * The configured style template out of the EDITED document, for visibleWhen + * predicates: visibility must follow the unsaved draft, so switching the + * template in the panel immediately shows or hides the options that theme + * consumes. + */ +function editedStyleTemplate(document: Record): unknown { + const configuration = document?.configuration as + | { general?: { styleTemplate?: unknown } } + | undefined; + return configuration?.general?.styleTemplate; +} /** * One label key per roster entry, `satisfies` so adding a template to the @@ -31,6 +45,7 @@ const STYLE_TEMPLATE_LABEL_KEYS = { developer: 'panel_configuration_general_style_template_developer_option', 'modern-gradient': 'panel_configuration_general_style_template_modern_gradient_option', + journal: 'panel_configuration_general_style_template_journal_option', } satisfies Record; export type ConfigFieldType = @@ -240,6 +255,11 @@ export function buildConfigFields( listType: { label: t('panel_configuration_instance_configuration_layout_list_type_label'), type: 'select', + // Declared unsupported by themes whose entry component is not + // list/grid switchable (Journal renders one column of plain + // entries). Hidden, not inert: the stored value is untouched. + visibleWhen: (document) => + isThemeOptionSupported(editedStyleTemplate(document), 'listType'), description: t('panel_configuration_instance_configuration_layout_list_type_description'), options: [ { value: 'list', label: t('panel_configuration_instance_configuration_layout_list_type_list_option') }, @@ -260,6 +280,10 @@ export function buildConfigFields( sidebar: { label: t('panel_configuration_instance_configuration_layout_sidebar_label'), type: 'section', + // Themes whose shell renders no sidebar declare it unsupported; + // the whole section hides rather than sitting there doing nothing. + visibleWhen: (document) => + isThemeOptionSupported(editedStyleTemplate(document), 'sidebar'), fields: { placement: { label: t('panel_configuration_instance_configuration_layout_sidebar_placement_label'), diff --git a/apps/self-hosted/src/styles/theme-appearance-tokens.test.ts b/apps/self-hosted/src/styles/theme-appearance-tokens.test.ts index 776001cc7b..e1ff1c034b 100644 --- a/apps/self-hosted/src/styles/theme-appearance-tokens.test.ts +++ b/apps/self-hosted/src/styles/theme-appearance-tokens.test.ts @@ -174,10 +174,10 @@ const accentBlocks = ALL_BLOCKS.filter((block) => describe('accent blocks', () => { it('are every light and dark palette the templates declare', () => { - // Five templates in two modes, plus the two base blocks in variables.css. + // Six templates in two modes, plus the two base blocks in variables.css. // A parser that stopped seeing them would make every check below vacuous. - expect(accentBlocks.length).toBe(12); - expect(new Set(accentBlocks.map((block) => block.file)).size).toBe(6); + expect(accentBlocks.length).toBe(14); + expect(new Set(accentBlocks.map((block) => block.file)).size).toBe(7); }); it('declare the chip text next to the accent that fills the chip', () => { @@ -326,7 +326,7 @@ describe('card treatment', () => { ); it('is stated by every template rather than inherited by accident', () => { - expect(templates.length).toBe(5); + expect(templates.length).toBe(6); const missing: string[] = []; for (const block of templates) { for (const token of CARD_TOKENS) { diff --git a/apps/self-hosted/src/styles/themes/index.css b/apps/self-hosted/src/styles/themes/index.css index e6bc4b0f70..3b9b9f7158 100644 --- a/apps/self-hosted/src/styles/themes/index.css +++ b/apps/self-hosted/src/styles/themes/index.css @@ -9,3 +9,4 @@ @import "./magazine.css"; @import "./developer.css"; @import "./modern-gradient.css"; +@import "./journal.css"; diff --git a/apps/self-hosted/src/styles/themes/journal.css b/apps/self-hosted/src/styles/themes/journal.css new file mode 100644 index 0000000000..164c184b36 --- /dev/null +++ b/apps/self-hosted/src/styles/themes/journal.css @@ -0,0 +1,119 @@ +/* Journal Theme - Ink on paper + * + * The first layout-level design: a single centered column, no cards, no + * sidebar, an author block up top. Serif throughout at a larger scale with + * generous leading; entries are date, title and excerpt separated by hairline + * rules. The structural half lives in src/themes/journal/ (Shell and PostCard + * manifest overrides); these tokens carry the full --theme-* contract so the + * accent correction sweep and every token utility keep working. + */ + +[data-style-template="journal"] { + /* Typography */ + --theme-font-body: "Source Serif 4", "Georgia", "Times New Roman", serif; + --theme-font-heading: "Source Serif 4", "Georgia", "Times New Roman", serif; + /* Quoted family names: not required by any lint gate in this repo today, + * but strings are exempt from keyword-casing rules, so a future stylelint + * adoption cannot flag this file. */ + --theme-font-ui: + -apple-system, "BlinkMacSystemFont", "Segoe UI", "Helvetica Neue", "Arial", + sans-serif; + + /* Typography Sizes */ + --theme-text-base: 19px; + --theme-leading-normal: 1.65; + --theme-tracking-normal: 0; + --theme-tracking-tight: -0.01em; + + /* Colors - Light: warm paper, near-black ink, burnt sienna accent */ + --theme-bg-primary: #faf8f4; + --theme-bg-secondary: #f2efe8; + --theme-bg-tertiary: rgba(34, 29, 23, 0.05); + --theme-bg-card: #faf8f4; + + --theme-text-primary: #221d17; + --theme-text-secondary: rgba(34, 29, 23, 0.75); + /* 0.62 composites to ~4.7:1 over the paper background: dates and + * marginalia stay quiet but AA-readable at 14px. */ + --theme-text-muted: rgba(34, 29, 23, 0.62); + + --theme-accent: #9c4a1e; + --theme-accent-hover: #7f3a15; + /* Ink on the accent fill: white on #9c4a1e is 5.4:1. */ + --theme-accent-contrast: #ffffff; + + --theme-border: rgba(34, 29, 23, 0.12); + --theme-border-strong: rgba(34, 29, 23, 0.2); + + /* Effects: paper has no elevation */ + --theme-radius-sm: 2px; + --theme-radius: 2px; + --theme-radius-lg: 4px; + --theme-radius-full: 9999px; + + --theme-shadow-sm: none; + --theme-shadow: none; + --theme-shadow-lg: none; + + /* Link underline style, derived from the accent; see variables.css */ + --theme-link-decoration: underline; + --theme-link-decoration-color: color-mix( + in srgb, + var(--theme-accent-text, var(--theme-accent)) 40%, + transparent + ); + --theme-link-decoration-hover: var(--theme-accent-text, var(--theme-accent)); + + /* Tag style: quiet, set in the UI face like marginalia */ + --theme-tag-text: var(--theme-accent-text-light, rgba(34, 29, 23, 0.65)); + --theme-tag-radius: 2px; + + /* Card treatment: none. An entry is the page surface with a hairline rule. */ + --theme-card-bg: var(--theme-bg-card); + --theme-card-border: 1px solid var(--theme-border); + --theme-card-backdrop: none; + + /* Layout: one measure-width column; the sidebar tokens keep the contract + * satisfied but the Journal shell renders no sidebar at all. */ + --theme-content-width: 680px; + --theme-sidebar-width: 280px; + --theme-layout-gap: 2.5rem; + --theme-layout-container-padding: 1.25rem; + --theme-layout-section-gap: 2.5rem; + --theme-card-padding: 0px; + --theme-grid-gap: 2.5rem; + --theme-grid-columns-tablet: 1; + --theme-grid-columns-desktop: 1; + --theme-post-card-image-height: 200px; + --theme-post-card-image-radius: 2px; +} + +/* Journal Dark Mode: warm charcoal, never blue-black */ +[data-style-template="journal"][data-theme="dark"] { + --theme-bg-primary: #17140f; + --theme-bg-secondary: #1f1b15; + --theme-bg-tertiary: rgba(247, 242, 234, 0.07); + --theme-bg-card: #17140f; + + --theme-text-primary: rgba(247, 242, 234, 0.92); + --theme-text-secondary: rgba(247, 242, 234, 0.75); + --theme-text-muted: rgba(247, 242, 234, 0.58); + + --theme-accent: #d78d5c; + --theme-accent-hover: #e5a377; + /* Ink on the accent fill: the same choice the correction module makes for + * this fill, so static CSS and runtime preview agree. 7.2:1. */ + --theme-accent-contrast: #111827; + + --theme-border: rgba(247, 242, 234, 0.12); + --theme-border-strong: rgba(247, 242, 234, 0.2); + + --theme-link-decoration-color: color-mix( + in srgb, + var(--theme-accent-text, var(--theme-accent)) 40%, + transparent + ); + --theme-link-decoration-hover: var(--theme-accent-text, var(--theme-accent)); + + --theme-tag-text: var(--theme-accent-text-dark, rgba(247, 242, 234, 0.65)); +} diff --git a/apps/self-hosted/src/themes/journal/journal-post-card.tsx b/apps/self-hosted/src/themes/journal/journal-post-card.tsx new file mode 100644 index 0000000000..2fb207c2d1 --- /dev/null +++ b/apps/self-hosted/src/themes/journal/journal-post-card.tsx @@ -0,0 +1,58 @@ +import { Link } from '@tanstack/react-router'; +import type { Entry } from '@ecency/sdk'; +import { postBodySummary } from '@ecency/render-helper'; +import { useMemo } from 'react'; +import { formatDate } from '@/core'; + +interface Props { + entry: Entry; + index?: number; +} + +/** + * A Journal entry: date, large serif title, excerpt, hairline rule. No card + * chrome, no image, no counters; the entry is the page surface, the way a + * personal publication reads. Stats and actions live on the post page. + */ +export function JournalPostCard({ entry }: Props) { + const entryData = entry.original_entry || entry; + + const summary = useMemo( + () => + entryData.json_metadata?.description || + postBodySummary(entryData.body, 220), + [entryData], + ); + + // Same canonical link shape the default card uses: the '@' is part of the + // post URL and the router leaves it unencoded. + const postParams = useMemo( + () => ({ author: `@${entryData.author}`, permlink: entryData.permlink }), + [entryData.author, entryData.permlink], + ); + const postSearch = { raw: undefined }; + + return ( +
+

+ + {entryData.community && entryData.community_title && ( + ยท {entryData.community_title} + )} +

+

+ + {entryData.title} + +

+ {summary && ( +

{summary}

+ )} +
+ ); +} diff --git a/apps/self-hosted/src/themes/journal/journal-shell.tsx b/apps/self-hosted/src/themes/journal/journal-shell.tsx new file mode 100644 index 0000000000..20a52a560f --- /dev/null +++ b/apps/self-hosted/src/themes/journal/journal-shell.tsx @@ -0,0 +1,107 @@ +import type { PropsWithChildren } from 'react'; +import { Link } from '@tanstack/react-router'; +import clsx from 'clsx'; +import { InstanceConfigManager } from '@/core'; +import { CreatePostButton, UserMenu } from '@/features/auth'; +import { SearchInput } from '@/features/blog/components/search-input'; +import { + useCommunityData, + useInstanceConfig, +} from '@/features/blog/hooks/use-instance-config'; +import { usePostsFilterState } from '@/features/blog/hooks/use-posts-filter-state'; + +/** + * The Journal page frame: one measure-width column, no sidebar at all, an + * author block as the masthead. The sidebar simply is not rendered (its + * config options are declared unsupported through the manifest, so the + * editor hides them under this theme instead of leaving them silently inert). + */ +export function JournalShell(props: PropsWithChildren) { + const { username, isCommunityMode } = useInstanceConfig(); + const { data: community } = useCommunityData(); + + const blogTitle = InstanceConfigManager.useConfig( + ({ configuration }) => configuration.instanceConfiguration.meta.title, + ); + const blogDescription = InstanceConfigManager.useConfig( + ({ configuration }) => configuration.instanceConfiguration.meta.description, + ); + const proxyBase = InstanceConfigManager.useConfig( + ({ configuration }) => + configuration.general.imageProxy || 'https://i.ecency.com', + ); + + const displayTitle = + isCommunityMode && community?.title ? community.title : blogTitle || username; + const avatarAccount = isCommunityMode ? community?.name : username; + const avatarUrl = avatarAccount + ? `${proxyBase}/u/${avatarAccount}/avatar/medium` + : null; + + // Shared with BlogNavigation, so the shell cannot drift from it. + const { availableFilters, currentFilter, filterLabel } = usePostsFilterState(); + + return ( +
+
+
+ {avatarUrl && ( + + )} +

+ + {displayTitle} + +

+ {blogDescription && ( +

+ {blogDescription} +

+ )} + + {/* flex-wrap: at narrow viewports the search and menu drop to their + own line instead of overflowing the measure-width column. */} +
+ + + + + +
+
+ +
+ {props.children} +
+
+ {/* The floating composer entry point the default navigation mounts; a + theme shell must never cost owners and community members the way in. */} + +
+ ); +} diff --git a/apps/self-hosted/src/themes/manifest.ts b/apps/self-hosted/src/themes/manifest.ts index c88396c6d9..67169071c9 100644 --- a/apps/self-hosted/src/themes/manifest.ts +++ b/apps/self-hosted/src/themes/manifest.ts @@ -33,6 +33,9 @@ export interface ThemeComponents { PostCard: ComponentType<{ entry: Entry; index?: number }>; } +/** Config options a theme can declare it does not consume. */ +export type ThemeOptionKey = 'sidebar' | 'listType'; + export interface ThemeManifest { id: StyleTemplate; /** @@ -40,6 +43,14 @@ export interface ThemeManifest { * activation; this field is what the server and the pickers read. */ tier: 'free' | 'premium'; + /** + * Config options this theme's components do not consume. The editor hides + * them while the theme is active (visibleWhen), which is the explicit + * declaration the manifest contract demands: a toggle must never be + * silently inert. Stored values are untouched and apply again the moment + * the owner switches back to a theme that consumes them. + */ + unsupportedOptions?: readonly ThemeOptionKey[]; /** * Component overrides for the named seams. Absent entirely for a CSS-only * template, which is what all five existing templates are: their manifests diff --git a/apps/self-hosted/src/themes/registry.test.ts b/apps/self-hosted/src/themes/registry.test.ts index 8c31746b44..c1e89def4f 100644 --- a/apps/self-hosted/src/themes/registry.test.ts +++ b/apps/self-hosted/src/themes/registry.test.ts @@ -15,13 +15,28 @@ describe('theme manifest registry', () => { expect(manifests.map((m) => m.id).sort()).toEqual([...STYLE_TEMPLATES].sort()); }); - it('all five existing templates are CSS-only no-op manifests', () => { + it('the five original templates stay CSS-only no-op manifests', () => { + // The no-op migration proof for the pre-manifest templates: their rendered + // tree is exactly the shared defaults. Journal is the first structural + // theme and is asserted separately below. + const cssOnly = ['medium', 'minimal', 'magazine', 'developer', 'modern-gradient']; for (const manifest of allThemeManifests()) { - expect(manifest.components, `${manifest.id} must not override components yet`).toBeUndefined(); + if (cssOnly.includes(manifest.id)) { + expect(manifest.components, `${manifest.id} must not override components`).toBeUndefined(); + } expect(manifest.tier).toBe('free'); } }); + it('journal owns its shell and entry, declares what it does not consume', () => { + const journal = getThemeManifest('journal'); + expect(journal.components?.Shell).toBeTypeOf('function'); + expect(journal.components?.PostCard).toBeTypeOf('function'); + // Navigation/Sidebar/ArchiveList fall back to the defaults. + expect(journal.components?.ArchiveList).toBeUndefined(); + expect(journal.unsupportedOptions).toEqual(['sidebar', 'listType']); + }); + it('unknown and absent ids resolve to the default template', () => { expect(getThemeManifest(undefined).id).toBe('medium'); expect(getThemeManifest('no-such-theme').id).toBe('medium'); @@ -39,8 +54,8 @@ const { DEFAULT_THEME_COMPONENTS, resolveThemeComponents } = await import( ); describe('component resolution', () => { - it('every roster template resolves to exactly the shared defaults today', () => { - for (const id of STYLE_TEMPLATES) { + it('every CSS-only template resolves to exactly the shared defaults', () => { + for (const id of STYLE_TEMPLATES.filter((t) => t !== 'journal')) { const resolved = resolveThemeComponents(id); // Identity per seam, not just deep equality: the no-op migration means // the very same component functions render, so nothing remounts. @@ -54,4 +69,23 @@ describe('component resolution', () => { resolveThemeComponents('medium'), ); }); + + it('journal resolves its own shell and entry, defaults for the rest', () => { + const journal = getThemeManifest('journal'); + const resolved = resolveThemeComponents('journal'); + expect(resolved.Shell).toBe(journal.components?.Shell); + expect(resolved.PostCard).toBe(journal.components?.PostCard); + expect(resolved.Navigation).toBe(DEFAULT_THEME_COMPONENTS.Navigation); + expect(resolved.Sidebar).toBe(DEFAULT_THEME_COMPONENTS.Sidebar); + expect(resolved.ArchiveList).toBe(DEFAULT_THEME_COMPONENTS.ArchiveList); + }); + + it('option support reads the manifest declaration', async () => { + const { isThemeOptionSupported } = await import('./registry'); + expect(isThemeOptionSupported('journal', 'sidebar')).toBe(false); + expect(isThemeOptionSupported('journal', 'listType')).toBe(false); + expect(isThemeOptionSupported('medium', 'sidebar')).toBe(true); + expect(isThemeOptionSupported(undefined, 'sidebar')).toBe(true); + expect(isThemeOptionSupported('no-such-theme', 'listType')).toBe(true); + }); }); diff --git a/apps/self-hosted/src/themes/registry.ts b/apps/self-hosted/src/themes/registry.ts index 5bd43c0e18..c25aafea23 100644 --- a/apps/self-hosted/src/themes/registry.ts +++ b/apps/self-hosted/src/themes/registry.ts @@ -3,7 +3,9 @@ import { STYLE_TEMPLATES, type StyleTemplate, } from '../../hosting/api/src/style-templates'; -import type { ThemeManifest } from './manifest'; +import { JournalPostCard } from './journal/journal-post-card'; +import { JournalShell } from './journal/journal-shell'; +import type { ThemeManifest, ThemeOptionKey } from './manifest'; /** * Every template id from the roster gets a manifest here; the roster guard @@ -19,6 +21,16 @@ const MANIFESTS: Record = { magazine: { id: 'magazine', tier: 'free' }, developer: { id: 'developer', tier: 'free' }, 'modern-gradient': { id: 'modern-gradient', tier: 'free' }, + // The first layout-level design: its own shell (single column, author + // block, no sidebar) and entry (no card chrome). Everything else falls back + // to the shared defaults, and the options its components do not consume are + // declared so the editor hides them under this theme. + journal: { + id: 'journal', + tier: 'free', + components: { Shell: JournalShell, PostCard: JournalPostCard }, + unsupportedOptions: ['sidebar', 'listType'], + }, }; function isStyleTemplate(value: unknown): value is StyleTemplate { @@ -40,3 +52,15 @@ export function getThemeManifest(configured: unknown): ThemeManifest { export function allThemeManifests(): readonly ThemeManifest[] { return STYLE_TEMPLATES.map((id) => MANIFESTS[id]); } + +/** + * Whether the configured template's components consume a config option. The + * editor's visibleWhen predicates read this, so hiding an option is always a + * manifest declaration rather than a hardcoded template name. + */ +export function isThemeOptionSupported( + configured: unknown, + option: ThemeOptionKey, +): boolean { + return !getThemeManifest(configured).unsupportedOptions?.includes(option); +}