-
Notifications
You must be signed in to change notification settings - Fork 7
Self-hosted: add a Gallery layout for image-led blogs #1470
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
80d2951
53bab1a
d8ddfbe
7003f23
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
| @@ -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"\]/); | ||
| } | ||
| }); | ||
| }); |
| 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); | ||
| } | ||
|
|
||
| :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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On Gallery instances, this global selector also matches the Useful? React with 👍 / 👎. |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,3 +11,4 @@ | |
| @import "./modern-gradient.css"; | ||
| @import "./journal.css"; | ||
| @import "./reader.css"; | ||
| @import "./gallery.css"; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When Gallery is active, this selector widens every
BlogPage, not only the archive: the post, search, and About routes all render throughDefaultShell, andBlogPostBodyexplicitly usesmax-w-none. Consequently, opening an article on a desktop expands its text from the existingmax-w-3xlreading measure to 1200px, producing very long lines. Scope the widening to the archive/search grid rather than the shared page wrapper.Useful? React with 👍 / 👎.