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 d2d1d1e73a..120a35305e 100644 --- a/apps/self-hosted/hosting/api/src/style-template-display.ts +++ b/apps/self-hosted/hosting/api/src/style-template-display.ts @@ -114,6 +114,17 @@ export const STYLE_TEMPLATE_DISPLAY = { }, headingStyle: 'sans', }, + terminal: { + name: 'Terminal', + tagline: 'A console listing: monospace, dense, no card in sight', + colors: { + background: '#0d1117', + surface: '#161b22', + accent: '#7ee787', + text: '#c9d1d9', + }, + headingStyle: 'mono', + }, } 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 978aa2f43e..a0df94be85 100644 --- a/apps/self-hosted/hosting/api/src/style-templates.ts +++ b/apps/self-hosted/hosting/api/src/style-templates.ts @@ -26,6 +26,7 @@ export const STYLE_TEMPLATES = Object.freeze([ 'journal', 'reader', 'gallery', + 'terminal', ] 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 335aa78fac..c2bd78acf4 100644 --- a/apps/self-hosted/src/core/i18n-strings.ts +++ b/apps/self-hosted/src/core/i18n-strings.ts @@ -263,6 +263,7 @@ export type TranslationKey = | 'panel_configuration_general_style_template_journal_option' | 'panel_configuration_general_style_template_reader_option' | 'panel_configuration_general_style_template_gallery_option' + | 'panel_configuration_general_style_template_terminal_option' | 'reader_home_hint' | 'reader_home_keys' | 'panel_configuration_general_language_label' @@ -605,6 +606,8 @@ export const translations: { en: Translations } & Record< 'Reader (split view, archive rail beside the post)', panel_configuration_general_style_template_gallery_option: 'Gallery (image grid, for picture-led blogs)', + panel_configuration_general_style_template_terminal_option: + 'Terminal (console listing, monospace)', reader_home_hint: 'Pick a post from the list to start reading.', reader_home_keys: 'Tip: j and k move between posts.', panel_configuration_general_language_label: 'Language', 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 bbafc09f8c..3fc3a40348 100644 --- a/apps/self-hosted/src/features/floating-menu/config-fields.ts +++ b/apps/self-hosted/src/features/floating-menu/config-fields.ts @@ -48,6 +48,7 @@ const STYLE_TEMPLATE_LABEL_KEYS = { journal: 'panel_configuration_general_style_template_journal_option', reader: 'panel_configuration_general_style_template_reader_option', gallery: 'panel_configuration_general_style_template_gallery_option', + terminal: 'panel_configuration_general_style_template_terminal_option', } satisfies Record; export type ConfigFieldType = diff --git a/apps/self-hosted/src/styles/terminal-layout.test.ts b/apps/self-hosted/src/styles/terminal-layout.test.ts new file mode 100644 index 0000000000..421a39a629 --- /dev/null +++ b/apps/self-hosted/src/styles/terminal-layout.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * Terminal's structure is a Shell and an ArchiveList, so unlike Gallery most + * of it is components rather than CSS. Two rules still carry weight and both + * are silent when broken, which is what this pins. + * + * The listing rule also has to survive a config that says `grid`: the feed + * setting predates the theme, and `apply-config-dom` falls back to `grid` + * when the key is absent, so a stored document can put a console listing + * into a grid unless this rule wins. + */ + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CSS = readFileSync(join(HERE, 'themes', 'terminal.css'), 'utf8'); + +function rules() { + const withoutComments = CSS.replace(/\/\*[\s\S]*?\*\//g, ''); + return [...withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({ + selector: m[1].replace(/\s+/g, ' ').trim(), + body: m[2], + })); +} + +const layoutRules = () => rules().filter((r) => r.selector.includes('.blog-')); + +describe('terminal layout rules', () => { + it('keeps the archive a listing whatever the feed setting says', () => { + const listing = layoutRules().find((r) => /\.blog-posts-list$/.test(r.selector)); + expect(listing).toBeDefined(); + expect(listing!.body).toMatch(/display:\s*flex/); + expect(listing!.body).toMatch(/flex-direction:\s*column/); + }); + + it('widens the measure only on a page that has the listing on it', () => { + // Without the :has, an article and the About page would render prose at + // the listing's 900px instead of a reading measure. + const measure = layoutRules().find((r) => /\.blog-page-measure/.test(r.selector)); + expect(measure).toBeDefined(); + expect(measure!.selector).toContain(':has(.blog-posts-list)'); + expect(measure!.body).toMatch(/max-width:\s*var\(--theme-content-width\)/); + }); + + it('carries no sidebar rules, because its shell renders no sidebar', () => { + // Gallery needs them: it keeps the shared shell and has to collapse the + // column that shell reserves. TerminalShell renders its own frame, so a + // rule hiding a sidebar here would match nothing and mislead the reader. + expect(CSS).not.toContain('.blog-sidebar-container'); + expect(CSS).not.toContain('.blog-layout-grid'); + }); + + it('keeps every layout selector rooted, so it outranks components.css', () => { + const selectors = layoutRules().map((r) => r.selector); + expect(selectors.length).toBeGreaterThanOrEqual(2); + for (const selector of selectors) { + expect(selector, selector).toMatch( + /^:root\[data-style-template="terminal"\]/, + ); + } + }); +}); 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 c78114592a..dc192193ca 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', () => { - // Eight templates in two modes, plus the two base blocks in variables.css. + // Nine 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(18); - expect(new Set(accentBlocks.map((block) => block.file)).size).toBe(9); + expect(accentBlocks.length).toBe(20); + expect(new Set(accentBlocks.map((block) => block.file)).size).toBe(10); }); 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(8); + expect(templates.length).toBe(9); 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 41a77e5445..4d1a2117bb 100644 --- a/apps/self-hosted/src/styles/themes/index.css +++ b/apps/self-hosted/src/styles/themes/index.css @@ -12,3 +12,4 @@ @import "./journal.css"; @import "./reader.css"; @import "./gallery.css"; +@import "./terminal.css"; diff --git a/apps/self-hosted/src/styles/themes/terminal.css b/apps/self-hosted/src/styles/themes/terminal.css new file mode 100644 index 0000000000..37e80e1748 --- /dev/null +++ b/apps/self-hosted/src/styles/themes/terminal.css @@ -0,0 +1,157 @@ +/* Terminal Theme - A console listing + * + * The fourth layout-level design. Developer was the template whose look + * implied a structure it did not have: dark and code-friendly, rendering the + * same cards as everything else. Terminal is that aesthetic as an actual + * layout, and it ships BESIDE Developer rather than replacing it, because + * independent deployments cannot be surveyed and an unknown template id + * clamps to the roster default: removing 'developer' would silently reset + * someone's blog to Medium. + * + * The structural half lives in src/themes/terminal/ (Shell and ArchiveList + * manifest overrides); these tokens carry the full --theme-* contract so the + * accent correction sweep and every token utility keep working. + * + * Monospace throughout on purpose, including headings: the idiom is a + * console, where everything is one face at one width. + */ + +[data-style-template="terminal"] { + /* Typography: one face, everywhere. */ + --theme-font-body: + ui-monospace, "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; + --theme-font-heading: + ui-monospace, "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; + --theme-font-ui: + ui-monospace, "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; + + --theme-text-base: 15px; + --theme-leading-normal: 1.6; + --theme-tracking-normal: 0; + --theme-tracking-tight: 0; + + /* Colors - Light: a pale terminal, for the owner who wants one. The + * accent is a deep green so it stays legible on paper as well as glass. */ + --theme-bg-primary: #f6f8fa; + --theme-bg-secondary: #eaeef2; + --theme-bg-tertiary: rgba(31, 35, 40, 0.06); + --theme-bg-card: #ffffff; + + --theme-text-primary: #1f2328; + --theme-text-secondary: rgba(31, 35, 40, 0.75); + /* 0.62 composites to ~4.6:1 over the ground. */ + --theme-text-muted: rgba(31, 35, 40, 0.62); + + --theme-accent: #116329; + --theme-accent-hover: #0d4f20; + /* Ink on the accent fill: white on #116329 is 7.4:1, and it is what the + * correction module picks, so static CSS and runtime preview agree. */ + --theme-accent-contrast: #ffffff; + + --theme-border: rgba(31, 35, 40, 0.15); + --theme-border-strong: rgba(31, 35, 40, 0.25); + + /* Effects: a terminal has corners, not curves, and no elevation. */ + --theme-radius-sm: 0px; + --theme-radius: 0px; + --theme-radius-lg: 2px; + --theme-radius-full: 0px; + + --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)); + + /* Tags read as shell tokens: square, quiet, the UI face like everything. */ + --theme-tag-text: var(--theme-accent-text-light, rgba(31, 35, 40, 0.72)); + --theme-tag-radius: 0px; + + /* Card treatment: a listing row is not a card. */ + --theme-card-bg: transparent; + --theme-card-border: none; + --theme-card-backdrop: none; + + /* Layout: a wide column, because a listing is columns of text. The sidebar + * tokens keep the contract satisfied; the Terminal shell renders none. */ + --theme-content-width: 900px; + --theme-sidebar-width: 280px; + --theme-layout-gap: 1.5rem; + --theme-layout-container-padding: 1rem; + --theme-layout-section-gap: 1.5rem; + --theme-card-padding: 0px; + --theme-grid-gap: 1rem; + --theme-grid-columns-tablet: 1; + --theme-grid-columns-desktop: 1; + --theme-post-card-image-height: 180px; + --theme-post-card-image-radius: 0px; +} + +/* Terminal Dark Mode: the home state. Deep slate ground, phosphor green. */ +[data-style-template="terminal"][data-theme="dark"] { + --theme-bg-primary: #0d1117; + --theme-bg-secondary: #161b22; + --theme-bg-tertiary: rgba(201, 209, 217, 0.08); + --theme-bg-card: #161b22; + + --theme-text-primary: #c9d1d9; + --theme-text-secondary: rgba(201, 209, 217, 0.78); + --theme-text-muted: rgba(201, 209, 217, 0.6); + + --theme-accent: #7ee787; + --theme-accent-hover: #a2f2a9; + /* The correction module's pick for this fill: 11.6:1. */ + --theme-accent-contrast: #111827; + + --theme-border: rgba(201, 209, 217, 0.15); + --theme-border-strong: rgba(201, 209, 217, 0.25); + + --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(201, 209, 217, 0.7)); +} + +/* + * The listing rules. Rooted like every theme's layout CSS: this file is + * imported BEFORE components.css, so an equal-specificity selector there + * would win. + * + * There are no sidebar rules here, unlike Gallery's. Gallery keeps the + * shared shell, so it has to collapse the column that shell reserves. + * TerminalShell renders its own frame with no sidebar in it at all, so a + * rule hiding one would never match anything. + */ + +/* + * The shell holds pages at a reading measure (max-w-3xl), which left the + * listing inset from the prompt line above it: a console whose header and + * body do not share a left edge does not read as one. Widened only where + * there IS a listing, so an article and the About page keep the measure + * that makes prose readable. + */ +:root[data-style-template="terminal"] .blog-page-measure:has(.blog-posts-list) { + max-width: var(--theme-content-width); +} + +/* A listing is rows, whatever the config's feed setting says: the layout is + * the point of the theme, and a stored 'grid' predates it. */ +:root[data-style-template="terminal"] .blog-posts-list { + display: flex; + flex-direction: column; + gap: 0; +} diff --git a/apps/self-hosted/src/themes/registry.test.ts b/apps/self-hosted/src/themes/registry.test.ts index 3c85699727..7ae045b8ea 100644 --- a/apps/self-hosted/src/themes/registry.test.ts +++ b/apps/self-hosted/src/themes/registry.test.ts @@ -67,7 +67,7 @@ const { DEFAULT_THEME_COMPONENTS, resolveThemeComponents } = await import( describe('component resolution', () => { it('every CSS-only template resolves to exactly the shared defaults', () => { - const layoutThemes = new Set(['journal', 'reader', 'gallery', 'magazine']); + const layoutThemes = new Set(['journal', 'reader', 'gallery', 'magazine', 'terminal']); for (const id of STYLE_TEMPLATES.filter((t) => !layoutThemes.has(t))) { const resolved = resolveThemeComponents(id); // Identity per seam, not just deep equality: the no-op migration means @@ -120,6 +120,16 @@ describe('component resolution', () => { expect(resolved.Navigation).toBe(DEFAULT_THEME_COMPONENTS.Navigation); }); + it('terminal owns its shell and listing, and mounts the composer entry', () => { + const terminal = getThemeManifest('terminal'); + const resolved = resolveThemeComponents('terminal'); + expect(resolved.Shell).toBe(terminal.components?.Shell); + expect(resolved.ArchiveList).toBe(terminal.components?.ArchiveList); + // The card stays shared: search results render through that seam, and a + // listing row has no meaning outside the archive. + expect(resolved.PostCard).toBe(DEFAULT_THEME_COMPONENTS.PostCard); + }); + it('reader resolves its own shell and archive pane, defaults for the rest', () => { const reader = getThemeManifest('reader'); const resolved = resolveThemeComponents('reader'); diff --git a/apps/self-hosted/src/themes/registry.ts b/apps/self-hosted/src/themes/registry.ts index 63ae006045..7acb7e19b9 100644 --- a/apps/self-hosted/src/themes/registry.ts +++ b/apps/self-hosted/src/themes/registry.ts @@ -9,6 +9,8 @@ import { ReaderHome } from './reader/reader-home'; import { MagazineArchive } from './magazine/magazine-archive'; import { GalleryPostCard } from './gallery/gallery-post-card'; import { GallerySidebar } from './gallery/gallery-sidebar'; +import { TerminalShell } from './terminal/terminal-shell'; +import { TerminalArchive } from './terminal/terminal-archive'; import { ReaderShell } from './reader/reader-shell'; import type { ThemeManifest, ThemeOptionKey } from './manifest'; @@ -68,6 +70,19 @@ const MANIFESTS: Record = { components: { PostCard: GalleryPostCard, Sidebar: GallerySidebar }, unsupportedOptions: ['sidebar', 'listType'], }, + // The fourth layout-level design: the Developer aesthetic as an actual + // console. Its own shell (prompt line, filters as flags, no sidebar) and + // its own archive (a dense listing, no cards). Ships beside Developer + // rather than replacing it: an unknown template id clamps to the roster + // default, so removing that id would silently reset an independent + // deployment's blog to Medium, and those cannot be surveyed. + terminal: { + id: 'terminal', + tier: 'free', + showsReadTime: true, + components: { Shell: TerminalShell, ArchiveList: TerminalArchive }, + unsupportedOptions: ['sidebar', 'listType'], + }, }; function isStyleTemplate(value: unknown): value is StyleTemplate { diff --git a/apps/self-hosted/src/themes/terminal/terminal-archive.tsx b/apps/self-hosted/src/themes/terminal/terminal-archive.tsx new file mode 100644 index 0000000000..745c8706a8 --- /dev/null +++ b/apps/self-hosted/src/themes/terminal/terminal-archive.tsx @@ -0,0 +1,117 @@ +import { Link } from '@tanstack/react-router'; +import type { Entry } from '@ecency/sdk'; +import { useMemo } from 'react'; +import { formatDate, t } from '@/core'; +import { estimateReadMinutes } from '@/features/blog/utils/read-time'; +import { useThemeShowsReadTime } from '@/themes/use-theme-components'; +import { ArchiveFrame } from '@/features/blog/components/archive-frame'; + +/** + * The Terminal archive: a listing, not a feed. One row per post, aligned in + * columns the way `ls -l` aligns, with the date first because that is the + * column a listing sorts by. + * + * No images, no excerpts, no counters. The whole point of the idiom is + * density: a reader sees thirty titles at once instead of five cards. + */ +export function TerminalArchive({ filter, limit }: { filter?: string; limit?: number }) { + return ( + + {({ posts }) => { + /* + * The date column is measured, not assumed. `general.dateFormat` is + * free-form, so a site can be running `MMMM d, yyyy` ("August 13, + * 2026") where the default renders `2026-08-13`. A fixed width made + * the long one wrap into the title column and destroy the alignment + * that is the whole point of a listing. + * + * `ch` is exact here because the theme is monospace throughout: one + * character is one advance width, so the widest formatted date in + * the batch gives a column that fits every row in it. + */ + const dated = posts.map((post) => ({ + post, + dateText: formatDate((post.original_entry || post).created), + })); + const dateColumn = dated.reduce((w, d) => Math.max(w, d.dateText.length), 0); + + return dated.map(({ post, dateText }) => ( + + )); + }} + + ); +} + +interface RowProps { + entry: Entry; + dateText: string; + /** Width of the date column in characters, the widest in the batch. */ + dateColumn: number; +} + +function TerminalRow({ entry, dateText, dateColumn }: RowProps) { + const entryData = entry.original_entry || entry; + + const showsReadTime = useThemeShowsReadTime(); + const readTime = useMemo( + () => (showsReadTime ? estimateReadMinutes(entryData.body) : null), + [showsReadTime, entryData.body], + ); + + // Same canonical link shape every other 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 ( +
+ + {/* + tabular-nums so digits share an advance width even if a font + substitutes, and nowrap so an unusually long format pushes the + column rather than folding onto a second line. + */} + + + {/* The prompt mark, purely decorative: a listing reads as a listing + because every line starts the same way. */} + + + + {entryData.title} + + + {readTime !== null && ( + + {readTime} {t('minRead')} + + )} + +
+ ); +} diff --git a/apps/self-hosted/src/themes/terminal/terminal-shell.tsx b/apps/self-hosted/src/themes/terminal/terminal-shell.tsx new file mode 100644 index 0000000000..4efd9943a5 --- /dev/null +++ b/apps/self-hosted/src/themes/terminal/terminal-shell.tsx @@ -0,0 +1,123 @@ +import type { PropsWithChildren } from 'react'; +import { Link } from '@tanstack/react-router'; +import clsx from 'clsx'; +import { InstanceConfigManager, t } 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'; +import { BlogPage } from '@/features/blog/layout/blog-page'; + +/** + * The Terminal page frame: a prompt line instead of a masthead, filters as + * flags after it, and no sidebar. Wide-ish column because a listing is + * columns of text rather than a measure of prose. + * + * It mounts CreatePostButton itself. That is the standing contract for every + * theme shell here: the default navigation is what mounts the composer + * entry, so a shell that replaces the navigation and forgets it silently + * removes the only way an owner writes a post. + */ +export function TerminalShell(props: PropsWithChildren) { + const { username, communityId, isCommunityMode } = useInstanceConfig(); + const { data: community } = useCommunityData(); + + const blogTitle = InstanceConfigManager.useConfig( + ({ configuration }) => configuration.instanceConfiguration.meta.title, + ); + const blogDescription = InstanceConfigManager.useConfig( + ({ configuration }) => configuration.instanceConfiguration.meta.description, + ); + + // Shared with BlogNavigation, so the shell cannot drift from it. + const { availableFilters, currentFilter, filterLabel, isAboutActive } = + usePostsFilterState(); + + // The prompt reads as a path. A community instance is keyed by its + // community id, not by `username`: a hand-written self-hosted config can + // leave username empty or set it to something unrelated, and the prompt + // would then name an account that has nothing to do with the site. + const identity = isCommunityMode ? communityId || username : username; + const prompt = `~/${identity}`; + + // Same preference the other shells use: the community's current on-chain + // title beats a title stored in the config, which goes stale the moment + // the community is renamed. + const displayTitle = + (isCommunityMode && community?.title) || blogTitle || identity; + + return ( +
+
+
+
+

+ + {' '} + {displayTitle} + +

+ + + + + +
+ + {blogDescription && ( +

+ + {blogDescription} +

+ )} + + {/* Filters as flags on the prompt line. Wrapping rather than + scrolling: a listing that scrolls sideways is not a listing. */} + +
+ +
+ {props.children} +
+
+
+ ); +}