Composite Studio overview sections

This commit is contained in:
Meta Vens 2026-04-21 16:16:52 +01:00
commit b0fb22331a
5 changed files with 362 additions and 2 deletions

View file

@ -1,5 +1,15 @@
---
title: Studio
enableToc: false
cssclasses:
- gamecult-composite-overview
compositeSections:
- Open-Source-Model
- Contributing
- democratizing-gamedev
- a-place-for-everyone
- games-as-a-service
- the-new-hotness
---
# Studio
@ -8,6 +18,8 @@ title: Studio
The studio section is where the older GameCult pitch turns into a durable shape: why the work is open, how people are meant to participate, what kind of projects fit here, and what the organization has historically promised to build around labor and governance.
The stitched studio narrative continues below. The standalone notes are still available from their own routes and in the sidebar.
## How We Work
- [Open Source Model](/Studio/Open-Source-Model)

View file

@ -1,5 +1,6 @@
import { QuartzConfig } from "./quartz/cfg"
import * as Plugin from "./quartz/plugins"
import * as Component from "./quartz/components"
/**
* Quartz 4 Configuration
@ -86,8 +87,12 @@ const config: QuartzConfig = {
emitters: [
Plugin.AliasRedirects(),
Plugin.ComponentResources(),
Plugin.ContentPage(),
Plugin.FolderPage(),
Plugin.ContentPage({
pageBody: Component.GameCultCompositeContent({ fallback: "content" }),
}),
Plugin.FolderPage({
pageBody: Component.GameCultCompositeContent({ fallback: "folder" }),
}),
Plugin.ContentIndex({
enableSiteMap: true,
enableRSS: true,

View file

@ -0,0 +1,210 @@
import { ComponentChildren } from "preact"
import { Element, ElementContent, Root } from "hast"
import { htmlToJsx } from "../util/jsx"
import { QuartzPluginData } from "../plugins/vfile"
import { clone } from "../util/clone"
import { normalizeHastElement, resolveRelative, type FullSlug } from "../util/path"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import FolderContent from "./pages/FolderContent"
interface Options {
fallback: "content" | "folder"
}
const defaultOptions: Options = {
fallback: "content",
}
type CompositeSection = {
content: ComponentChildren
leadFigure?: ComponentChildren
sectionId: string
slug: FullSlug
title: string
}
function isElement(node: ElementContent): node is Element {
return node.type === "element"
}
function folderSlug(slug: FullSlug) {
return slug === "index" ? "" : slug.replace(/\/index$/, "")
}
function resolveCompositeSlug(currentSlug: FullSlug, value: string) {
const normalized = value
.trim()
.replace(/^\//, "")
.replace(/\.md$/i, "")
.replace(/\/$/, "")
if (normalized.length === 0) {
return undefined
}
if (normalized.includes("/")) {
return normalized as FullSlug
}
const folder = folderSlug(currentSlug)
return (folder.length > 0 ? `${folder}/${normalized}` : normalized) as FullSlug
}
function buildSectionContent(parentSlug: FullSlug, file: QuartzPluginData): CompositeSection | undefined {
if (!file.slug || !file.htmlAst || !file.filePath) {
return undefined
}
const root = clone(file.htmlAst) as Root
const children = [...root.children]
let firstMeaningfulIndex = -1
for (let i = 0; i < children.length; i++) {
const node = children[i]
if (node.type === "text" && node.value.trim().length === 0) {
continue
}
firstMeaningfulIndex = i
break
}
if (firstMeaningfulIndex >= 0) {
const node = children[firstMeaningfulIndex]
if (isElement(node) && node.tagName === "h1") {
children.splice(firstMeaningfulIndex, 1)
}
}
let leadFigure: Element | undefined
for (let i = 0; i < children.length; i++) {
const node = children[i]
if (node.type === "text" && node.value.trim().length === 0) {
continue
}
if (isElement(node) && node.tagName === "figure") {
leadFigure = node
children.splice(i, 1)
}
break
}
const normalizedChildren = children.map((child) =>
normalizeHastElement(child as Element, parentSlug, file.slug as FullSlug),
) as ElementContent[]
const sectionRoot: Root = {
type: "root",
children: normalizedChildren,
}
const normalizedFigure = leadFigure
? normalizeHastElement(leadFigure, parentSlug, file.slug as FullSlug)
: undefined
const figureRoot: Root | undefined = normalizedFigure
? {
type: "root",
children: [normalizedFigure],
}
: undefined
return {
content: htmlToJsx(file.filePath, sectionRoot) as ComponentChildren,
leadFigure: figureRoot ? (htmlToJsx(file.filePath, figureRoot) as ComponentChildren) : undefined,
sectionId: file.slug.split("/").at(-1)?.toLowerCase() ?? "section",
slug: file.slug as FullSlug,
title: file.frontmatter?.title ?? file.slug.split("/").at(-1) ?? "Section",
}
}
function getCompositeSections(
fileData: QuartzPluginData,
allFiles: QuartzPluginData[],
): CompositeSection[] {
const currentSlug = fileData.slug as FullSlug | undefined
const configured = fileData.frontmatter?.compositeSections
if (!currentSlug || !Array.isArray(configured) || configured.length === 0) {
return []
}
const bySlug = new Map(
allFiles
.filter((file) => typeof file.slug === "string")
.map((file) => [file.slug as FullSlug, file]),
)
return configured
.map((entry) => (typeof entry === "string" ? resolveCompositeSlug(currentSlug, entry) : undefined))
.filter((entry): entry is FullSlug => entry !== undefined)
.map((slug) => bySlug.get(slug))
.filter((file): file is QuartzPluginData => file !== undefined)
.map((file) => buildSectionContent(currentSlug, file))
.filter((section): section is CompositeSection => section !== undefined)
}
export default ((opts?: Partial<Options>) => {
const options: Options = { ...defaultOptions, ...opts }
const DefaultFolderContent = FolderContent()
const GameCultCompositeContent: QuartzComponent = (props: QuartzComponentProps) => {
const { fileData, tree, allFiles } = props
const baseContent = htmlToJsx(fileData.filePath!, tree) as ComponentChildren
const classes: string[] = fileData.frontmatter?.cssclasses ?? []
const articleClass = ["popover-hint", ...classes].join(" ")
const sections = getCompositeSections(fileData, allFiles)
if (sections.length === 0) {
if (options.fallback === "folder") {
return <DefaultFolderContent {...props} />
}
return <article class={articleClass}>{baseContent}</article>
}
return (
<div class="popover-hint gamecult-composite-page">
<article class={articleClass}>{baseContent}</article>
<nav class="gamecult-composite-jump" aria-label={`${fileData.frontmatter?.title ?? "Page"} sections`}>
{sections.map((section) => (
<a href={`#${section.sectionId}`} class="gamecult-nav-chip">
{section.title}
</a>
))}
</nav>
<div class="gamecult-composite-sections">
{sections.map((section) => (
<section id={section.sectionId} class="gamecult-composite-section">
<div class="gamecult-composite-divider" aria-hidden="true" />
<div
class={`gamecult-composite-shell${section.leadFigure ? " has-media" : ""}`}
data-section-slug={section.slug}
>
{section.leadFigure && (
<div class="gamecult-composite-media">
{section.leadFigure}
</div>
)}
<article class="gamecult-composite-copy">
<header class="gamecult-composite-header">
<h2>{section.title}</h2>
<p class="gamecult-composite-standalone">
<a href={resolveRelative(fileData.slug as FullSlug, section.slug)}>
Open standalone note
</a>
</p>
</header>
<div class="gamecult-composite-body">{section.content}</div>
</article>
</div>
</section>
))}
</div>
</div>
)
}
return GameCultCompositeContent
}) satisfies QuartzComponentConstructor<Partial<Options>>

View file

@ -23,6 +23,7 @@ import Breadcrumbs from "./Breadcrumbs"
import Comments from "./Comments"
import Flex from "./Flex"
import ConditionalRender from "./ConditionalRender"
import GameCultCompositeContent from "./GameCultCompositeContent"
export {
ArticleTitle,
@ -50,4 +51,5 @@ export {
Comments,
Flex,
ConditionalRender,
GameCultCompositeContent,
}

View file

@ -542,6 +542,123 @@ a:hover,
line-height: 1.5;
}
.gamecult-composite-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
.gamecult-composite-overview > h2,
.gamecult-composite-overview > ul {
display: none;
}
.gamecult-composite-jump {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin: 0;
padding: 0 0.15rem;
}
.gamecult-composite-sections {
display: flex;
flex-direction: column;
gap: 1.45rem;
}
.gamecult-composite-section {
position: relative;
}
.gamecult-composite-divider {
height: 1.35rem;
margin: 0 1.2rem -0.25rem;
background: linear-gradient(
90deg,
rgba(89, 183, 255, 0.1),
rgba(255, 138, 42, 0.28),
rgba(89, 183, 255, 0.08)
);
clip-path: polygon(0 24%, 100% 0, 100% 74%, 70% 100%, 0 82%);
opacity: 0.75;
}
.gamecult-composite-shell {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
padding: 1.1rem;
border-radius: 24px;
border: 1px solid rgba(89, 183, 255, 0.14);
background: linear-gradient(180deg, rgba(8, 13, 21, 0.8), rgba(8, 13, 21, 0.72));
box-shadow:
0 22px 44px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
backdrop-filter: blur(14px);
}
.gamecult-composite-shell.has-media {
grid-template-columns: minmax(280px, 0.88fr) minmax(0, 1.12fr);
align-items: stretch;
}
.gamecult-composite-media {
min-width: 0;
}
.gamecult-composite-media > figure {
height: 100%;
margin: 0;
}
.gamecult-composite-media > figure img {
min-height: 280px;
}
.gamecult-composite-copy {
min-width: 0;
padding: 0.15rem 0.1rem 0.2rem;
}
.gamecult-composite-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.6rem 1.2rem;
align-items: baseline;
margin-bottom: 0.9rem;
}
.gamecult-composite-header h2 {
margin: 0;
color: var(--dark);
font-family: var(--titleFont);
font-size: clamp(1.55rem, 2.4vw, 2.2rem);
font-weight: 200;
line-height: 0.96;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.gamecult-composite-standalone {
margin: 0;
font-family: var(--titleFont);
font-size: 0.76rem;
font-weight: 300;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.gamecult-composite-body > :first-child {
margin-top: 0;
}
.gamecult-composite-body figure {
margin-left: 0;
margin-right: 0;
}
.gamecult-home-hero {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr);
@ -656,6 +773,20 @@ a:hover,
}
@media all and ($mobile) {
.gamecult-composite-shell,
.gamecult-composite-shell.has-media {
grid-template-columns: minmax(0, 1fr);
padding: 0.95rem;
}
.gamecult-composite-divider {
margin: 0 0.7rem -0.2rem;
}
.gamecult-composite-header {
align-items: start;
}
.gamecult-hero-panel {
padding: 1rem;
}