From ce7e88daabdbaeb6d2ca27d30e012714263a999c Mon Sep 17 00:00:00 2001 From: feruzm Date: Thu, 13 Aug 2026 10:44:48 +0000 Subject: [PATCH 1/2] self-hosted: add a Terminal layout, the Developer aesthetic as a console 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. Its own shell (a prompt line instead of a masthead, filters as flags, no sidebar) and its own archive: a dense listing of one row per post, dates in a tabular column, no images and no cards, so a reader sees thirty titles where a feed shows five. It ships beside Developer rather than replacing it. An unknown template id clamps to the roster default, so retiring that id would silently reset an independent deployment's blog to Medium, and those cannot be surveyed. --- .../hosting/api/src/style-template-display.ts | 11 ++ .../hosting/api/src/style-templates.ts | 1 + apps/self-hosted/src/core/i18n-strings.ts | 3 + .../features/floating-menu/config-fields.ts | 1 + .../src/styles/terminal-layout.test.ts | 64 +++++++ .../styles/theme-appearance-tokens.test.ts | 8 +- apps/self-hosted/src/styles/themes/index.css | 1 + .../src/styles/themes/terminal.css | 157 ++++++++++++++++++ apps/self-hosted/src/themes/registry.test.ts | 12 +- apps/self-hosted/src/themes/registry.ts | 15 ++ .../src/themes/terminal/terminal-archive.tsx | 80 +++++++++ .../src/themes/terminal/terminal-shell.tsx | 110 ++++++++++++ 12 files changed, 458 insertions(+), 5 deletions(-) create mode 100644 apps/self-hosted/src/styles/terminal-layout.test.ts create mode 100644 apps/self-hosted/src/styles/themes/terminal.css create mode 100644 apps/self-hosted/src/themes/terminal/terminal-archive.tsx create mode 100644 apps/self-hosted/src/themes/terminal/terminal-shell.tsx 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..1b1f5b6f63 --- /dev/null +++ b/apps/self-hosted/src/themes/terminal/terminal-archive.tsx @@ -0,0 +1,80 @@ +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 }) => posts.map((post) => )} + + ); +} + +function TerminalRow({ entry }: { entry: Entry }) { + 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 the dates form a real column: proportional digits + make a monospace listing ragged, which is the one thing a listing + cannot be. Fixed width for the same reason. + */} + + + {/* 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..0485a94a7c --- /dev/null +++ b/apps/self-hosted/src/themes/terminal/terminal-shell.tsx @@ -0,0 +1,110 @@ +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 { 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, isCommunityMode } = useInstanceConfig(); + + 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 is a directory of many authors, + // a blog is one person's home. + const prompt = isCommunityMode ? `~/${username}` : `~/${username}`; + + return ( +
+
+
+
+

+ + {' '} + {blogTitle || username} + +

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

+ + {blogDescription} +

+ )} + + {/* Filters as flags on the prompt line. Wrapping rather than + scrolling: a listing that scrolls sideways is not a listing. */} + +
+ +
+ {props.children} +
+
+
+ ); +} From 600900dd07dea0e9c182a2c2dd6407b18e2f270e Mon Sep 17 00:00:00 2001 From: feruzm Date: Thu, 13 Aug 2026 11:01:24 +0000 Subject: [PATCH 2/2] self-hosted: key the Terminal prompt to the community, measure the date column --- .../src/themes/terminal/terminal-archive.tsx | 53 ++++++++++++++++--- .../src/themes/terminal/terminal-shell.tsx | 25 ++++++--- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/apps/self-hosted/src/themes/terminal/terminal-archive.tsx b/apps/self-hosted/src/themes/terminal/terminal-archive.tsx index 1b1f5b6f63..745c8706a8 100644 --- a/apps/self-hosted/src/themes/terminal/terminal-archive.tsx +++ b/apps/self-hosted/src/themes/terminal/terminal-archive.tsx @@ -17,12 +17,45 @@ import { ArchiveFrame } from '@/features/blog/components/archive-frame'; export function TerminalArchive({ filter, limit }: { filter?: string; limit?: number }) { return ( - {({ posts }) => posts.map((post) => )} + {({ 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 }) => ( + + )); + }} ); } -function TerminalRow({ entry }: { entry: Entry }) { +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(); @@ -48,20 +81,24 @@ function TerminalRow({ entry }: { entry: Entry }) { className="group flex items-baseline gap-3 py-2 no-underline text-theme-primary hover:bg-theme-tertiary transition-theme" > {/* - tabular-nums so the dates form a real column: proportional digits - make a monospace listing ragged, which is the one thing a listing - cannot be. Fixed width for the same reason. + 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. */} -