Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/self-hosted/hosting/api/src/style-template-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StyleTemplate, StyleTemplateDisplay>;

export function templateCatalog() {
Expand Down
1 change: 1 addition & 0 deletions apps/self-hosted/hosting/api/src/style-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const STYLE_TEMPLATES = Object.freeze([
'journal',
'reader',
'gallery',
'terminal',
] as const);

export type StyleTemplate = (typeof STYLE_TEMPLATES)[number];
Expand Down
3 changes: 3 additions & 0 deletions apps/self-hosted/src/core/i18n-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<StyleTemplate, TranslationKey>;

export type ConfigFieldType =
Expand Down
64 changes: 64 additions & 0 deletions apps/self-hosted/src/styles/terminal-layout.test.ts
Original file line number Diff line number Diff line change
@@ -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"\]/,
);
}
});
});
8 changes: 4 additions & 4 deletions apps/self-hosted/src/styles/theme-appearance-tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions apps/self-hosted/src/styles/themes/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
@import "./journal.css";
@import "./reader.css";
@import "./gallery.css";
@import "./terminal.css";
157 changes: 157 additions & 0 deletions apps/self-hosted/src/styles/themes/terminal.css
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 11 additions & 1 deletion apps/self-hosted/src/themes/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down
15 changes: 15 additions & 0 deletions apps/self-hosted/src/themes/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -68,6 +70,19 @@ const MANIFESTS: Record<StyleTemplate, ThemeManifest> = {
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 {
Expand Down
Loading
Loading