Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -103,6 +103,17 @@ export const STYLE_TEMPLATE_DISPLAY = {
},
headingStyle: 'sans',
},
gallery: {
name: 'Gallery',
tagline: 'A wall of pictures: the image leads, the words step back',
colors: {
background: '#f6f6f4',
surface: '#ffffff',
accent: '#37596b',
text: '#1b1b1a',
},
headingStyle: 'sans',
},
} 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 @@ -25,6 +25,7 @@ export const STYLE_TEMPLATES = Object.freeze([
'modern-gradient',
'journal',
'reader',
'gallery',
] 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 @@ -262,6 +262,7 @@ export type TranslationKey =
| 'panel_configuration_general_style_template_modern_gradient_option'
| 'panel_configuration_general_style_template_journal_option'
| 'panel_configuration_general_style_template_reader_option'
| 'panel_configuration_general_style_template_gallery_option'
| 'reader_home_hint'
| 'reader_home_keys'
| 'panel_configuration_general_language_label'
Expand Down Expand Up @@ -602,6 +603,8 @@ export const translations: { en: Translations } & Record<
'Journal (single column, serif, no sidebar)',
panel_configuration_general_style_template_reader_option:
'Reader (split view, archive rail beside the post)',
panel_configuration_general_style_template_gallery_option:
'Gallery (image grid, for picture-led blogs)',
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
12 changes: 11 additions & 1 deletion apps/self-hosted/src/features/blog/layout/blog-page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import type { PropsWithChildren } from 'react';

/**
* The reading measure inside the shared shell. `max-w-3xl` stays the width
* every template has always had; `blog-page-measure` is a styling hook so a
* theme whose archive is not a column of text (Gallery's grid of covers) can
* widen it from its own stylesheet without changing anyone else's measure.
*/
export function BlogPage(props: PropsWithChildren) {
return <div className="max-w-3xl mx-auto w-full">{props.children}</div>;
return (
<div className="blog-page-measure max-w-3xl mx-auto w-full">
{props.children}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const STYLE_TEMPLATE_LABEL_KEYS = {
'panel_configuration_general_style_template_modern_gradient_option',
journal: 'panel_configuration_general_style_template_journal_option',
reader: 'panel_configuration_general_style_template_reader_option',
gallery: 'panel_configuration_general_style_template_gallery_option',
} satisfies Record<StyleTemplate, TranslationKey>;

export type ConfigFieldType =
Expand Down
64 changes: 64 additions & 0 deletions apps/self-hosted/src/styles/gallery-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';

/**
* Gallery is the first layout-level theme that keeps the shared shell: its
* structure is three CSS rules rather than a Shell component. That makes the
* rules load-bearing and silent when broken, which is what this pins.
*
* Delete the grid rule and Gallery renders a single column of covers, which
* looks like a styling choice rather than a bug. Delete the measure rule and
* the wall is capped at the reading width the text templates use. Weaken the
* selectors and components.css wins, because it is imported AFTER the themes.
*/

const HERE = dirname(fileURLToPath(import.meta.url));
const CSS = readFileSync(join(HERE, 'themes', 'gallery.css'), 'utf8');

/** The declarations of the first rule whose selector matches exactly. */
function rule(selector: string): string | null {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(CSS);
return match ? match[1] : null;
}

describe('gallery layout rules', () => {
it('turns the archive into a grid whatever list type the config carries', () => {
// listType is hidden by the manifest, but a config written before that
// (or by hand) can still say 'list', and the grid has to hold anyway.
const archive = rule(':root[data-style-template="gallery"] .blog-posts-list');
expect(archive).not.toBeNull();
expect(archive).toMatch(/display:\s*grid/);
expect(archive).toMatch(/grid-template-columns:\s*repeat\(auto-fill/);
});

it('widens the shared reading measure to the theme content width', () => {
const measure = rule(':root[data-style-template="gallery"] .blog-page-measure');
expect(measure).not.toBeNull();
expect(measure).toMatch(/max-width:\s*var\(--theme-content-width\)/);
});

it('collapses the sidebar column the shell reserves', () => {
expect(
rule(':root[data-style-template="gallery"] .blog-sidebar-container'),
).toMatch(/display:\s*none/);
expect(
rule(':root[data-style-template="gallery"] .blog-layout-grid'),
).toMatch(/grid-template-columns:\s*1fr/);
});

it('keeps every layout selector rooted, so it outranks components.css', () => {
// components.css is imported after the themes, so an equal-specificity
// selector there would win. `:root` is the step that prevents it, and a
// "tidy up" that drops it would break the layout with nothing else failing.
const layoutSelectors = [...CSS.matchAll(/^(\S.*)\{/gm)]
.map((m) => m[1].trim())
.filter((s) => s.includes('.blog-'));
expect(layoutSelectors.length).toBeGreaterThanOrEqual(4);
for (const selector of layoutSelectors) {
expect(selector, selector).toMatch(/^:root\[data-style-template="gallery"\]/);
}
});
});
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', () => {
// Seven templates in two modes, plus the two base blocks in variables.css.
// Eight 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(16);
expect(new Set(accentBlocks.map((block) => block.file)).size).toBe(8);
expect(accentBlocks.length).toBe(18);
expect(new Set(accentBlocks.map((block) => block.file)).size).toBe(9);
});

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(7);
expect(templates.length).toBe(8);
const missing: string[] = [];
for (const block of templates) {
for (const token of CARD_TOKENS) {
Expand Down
166 changes: 166 additions & 0 deletions apps/self-hosted/src/styles/themes/gallery.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/* Gallery Theme - A wall of pictures
*
* The third layout-level design, for blogs whose posts ARE the images: the
* archive is a grid of covers rather than a list of headlines, and the
* chrome recedes so nothing competes with the pictures. The structural half
* lives in src/themes/gallery/ (PostCard and a Sidebar that renders nothing);
* these tokens carry the full --theme-* contract so the accent correction
* sweep and every token utility keep working.
*
* The palette is a gallery wall on purpose: near-neutral grounds and a
* desaturated slate accent, because a saturated accent beside a photograph
* fights it. The two rules at the bottom are the layout itself.
*/

[data-style-template="gallery"] {
/* Typography: the UI face throughout. Captions, not headlines. */
--theme-font-body:
-apple-system, "BlinkMacSystemFont", "Segoe UI", "Helvetica Neue", "Arial",
sans-serif;
--theme-font-heading:
-apple-system, "BlinkMacSystemFont", "Segoe UI", "Helvetica Neue", "Arial",
sans-serif;
--theme-font-ui:
-apple-system, "BlinkMacSystemFont", "Segoe UI", "Helvetica Neue", "Arial",
sans-serif;

--theme-text-base: 16px;
--theme-leading-normal: 1.55;
--theme-tracking-normal: 0;
--theme-tracking-tight: -0.011em;

/* Colors - Light: gallery wall, near-black label text, slate accent */
--theme-bg-primary: #f6f6f4;
--theme-bg-secondary: #ffffff;
--theme-bg-tertiary: rgba(27, 27, 26, 0.05);
--theme-bg-card: #ffffff;

--theme-text-primary: #1b1b1a;
--theme-text-secondary: rgba(27, 27, 26, 0.72);
/* 0.6 composites to ~4.6:1 over the wall: captions stay quiet but AA. */
--theme-text-muted: rgba(27, 27, 26, 0.6);

--theme-accent: #37596b;
--theme-accent-hover: #2b4655;
/* Ink on the accent fill: white on #37596b is 7.5:1, and it is what the
* correction module picks for this fill, so CSS and runtime preview agree. */
--theme-accent-contrast: #ffffff;

--theme-border: rgba(27, 27, 26, 0.1);
--theme-border-strong: rgba(27, 27, 26, 0.18);

/* Effects: a picture needs no frame, so the radius is small and the
* elevation is a hairline rather than a drop shadow. */
--theme-radius-sm: 3px;
--theme-radius: 4px;
--theme-radius-lg: 6px;
--theme-radius-full: 9999px;

--theme-shadow-sm: none;
--theme-shadow: none;
--theme-shadow-lg: 0 8px 30px rgba(27, 27, 26, 0.1);

/* 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: small and quiet, the way a wall label is set */
--theme-tag-text: var(--theme-accent-text-light, rgba(27, 27, 26, 0.72));
--theme-tag-radius: 3px;

/* Card treatment: the image is the card. No fill, no border, no backdrop,
* so a cover sits directly on the wall. */
--theme-card-bg: transparent;
--theme-card-border: none;
--theme-card-backdrop: none;

/* Layout: wide, because a grid of pictures wants the room. The sidebar
* tokens keep the contract satisfied; Gallery renders no sidebar. */
--theme-content-width: 1200px;
--theme-sidebar-width: 280px;
--theme-layout-gap: 2rem;
--theme-layout-container-padding: 1.25rem;
--theme-layout-section-gap: 2rem;
--theme-card-padding: 0px;
--theme-grid-gap: 1.25rem;
--theme-grid-columns-tablet: 2;
--theme-grid-columns-desktop: 3;
--theme-post-card-image-height: 240px;
--theme-post-card-image-radius: 4px;
}

/* Gallery Dark Mode: the room with the lights down, the way a photo viewer
* goes dark so the picture carries the brightness. */
[data-style-template="gallery"][data-theme="dark"] {
--theme-bg-primary: #121212;
--theme-bg-secondary: #1a1a1a;
--theme-bg-tertiary: rgba(246, 246, 244, 0.07);
--theme-bg-card: #1a1a1a;

--theme-text-primary: rgba(246, 246, 244, 0.93);
--theme-text-secondary: rgba(246, 246, 244, 0.74);
--theme-text-muted: rgba(246, 246, 244, 0.58);

--theme-accent: #8fb8cc;
--theme-accent-hover: #a5c8d9;
/* The correction module's pick for this fill: 8.4:1. */
--theme-accent-contrast: #111827;

--theme-border: rgba(246, 246, 244, 0.12);
--theme-border-strong: rgba(246, 246, 244, 0.2);

--theme-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);

--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(246, 246, 244, 0.62));
}

/*
* The layout. Two rules, both needing to beat components.css, which is
* imported AFTER this file: `:root[data-style-template="gallery"]` is one
* specificity step above the `[data-list-type="grid"] .blog-posts-list`
* rules there, so the grid holds whichever list type the config carries.
*
* auto-fill with a minimum rather than a fixed column count: a gallery
* should reflow to the window, and it keeps the single-column phone case
* working without another breakpoint.
*/
/*
* The shared shell holds the archive at a reading measure (max-w-3xl, the
* width every template has always used). A wall of pictures wants the room
* instead, so Gallery widens that one wrapper to its own content-width
* token. Unlayered, so it beats the Tailwind utility whatever the
* specificity; every other template keeps the measure untouched.
*/
:root[data-style-template="gallery"] .blog-page-measure {
max-width: var(--theme-content-width);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep article pages at the reading measure

When Gallery is active, this selector widens every BlogPage, not only the archive: the post, search, and About routes all render through DefaultShell, and BlogPostBody explicitly uses max-w-none. Consequently, opening an article on a desktop expands its text from the existing max-w-3xl reading measure to 1200px, producing very long lines. Scope the widening to the archive/search grid rather than the shared page wrapper.

Useful? React with 👍 / 👎.

}

:root[data-style-template="gallery"] .blog-posts-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
gap: var(--theme-grid-gap);
align-items: start;
}

/* No sidebar: the grid is the page. The Sidebar seam already renders
* nothing, so this only collapses the column the shell's grid reserves for
* it at desktop widths. */
:root[data-style-template="gallery"] .blog-layout-grid {
grid-template-columns: 1fr;
}

:root[data-style-template="gallery"] .blog-sidebar-container {
display: none;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid hiding direct editor sidebars with archive CSS

On Gallery instances, this global selector also matches the /publish and /edit/$author/$permlink layouts, which use blog-sidebar-container but directly render BlogSidebar rather than the theme's null Sidebar seam. The sidebar therefore disappears while its account/community hooks and useQuery calls still mount and issue the requests this theme is intended to avoid. Scope this rule to DefaultShell or make those routes resolve the theme sidebar seam.

Useful? React with 👍 / 👎.

}
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 @@ -11,3 +11,4 @@
@import "./modern-gradient.css";
@import "./journal.css";
@import "./reader.css";
@import "./gallery.css";
Loading
Loading