Added PageComponents
This commit is contained in:
4
public/robots.txt
Normal file
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://dave-dmg.de/sitemap.xml
|
||||||
@@ -9,15 +9,18 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { articles, title } = Astro.props
|
const { articles, title } = Astro.props
|
||||||
|
const level = title ? 3 : 2
|
||||||
---
|
---
|
||||||
|
|
||||||
{articles.length > 0 && (
|
{articles.length > 0 && (
|
||||||
<section class="cards">
|
<section class="article-card-grid">
|
||||||
{title && <h2 class="title">{title}</h2>}
|
{title && <h2 class="article-card-grid__title">{title}</h2>}
|
||||||
<div class="grid">
|
<ul class="article-card-grid__list" role="list">
|
||||||
{articles.map((article) => (
|
{articles.map((article) => (
|
||||||
<ArticleCardItem article={article} />
|
<li class="article-card-grid__item">
|
||||||
|
<ArticleCardItem article={article} level={level} />
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,27 +5,37 @@ import type { Article } from '@/lib/site/types'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
article: Article
|
article: Article
|
||||||
|
level?: 2 | 3
|
||||||
}
|
}
|
||||||
|
|
||||||
const { article } = Astro.props
|
const { article, level = 2 } = Astro.props
|
||||||
|
|
||||||
const { cover, excerpt } = article.summary
|
const { cover, excerpt } = article.summary
|
||||||
const date = article.publishedAt
|
const date = article.publishedAt
|
||||||
|
const Heading = level === 2 ? 'h2' : 'h3'
|
||||||
---
|
---
|
||||||
|
|
||||||
<article class="card">
|
<article class:list={['article-card', cover.effect !== 'none' && `effect--${cover.effect}`]}>
|
||||||
<a class="link" href={article.path}>
|
{cover.image && (
|
||||||
{cover.image && (
|
<div class="article-card__cover">
|
||||||
<div class="cover">
|
<ContentImage
|
||||||
<ContentImage src={cover.image} alt={cover.alt} fit />
|
src={cover.image}
|
||||||
</div>
|
alt=""
|
||||||
)}
|
widths={[400, 800, 1200]}
|
||||||
<h3 class="title">{article.title}</h3>
|
sizes="(min-width: 80rem) 24rem, (min-width: 48rem) 50vw, 100vw"
|
||||||
</a>
|
/>
|
||||||
{excerpt && <p class="excerpt">{excerpt}</p>}
|
</div>
|
||||||
{date && (
|
|
||||||
<time class="date" datetime={date.toISOString()}>
|
|
||||||
{toMilitaryDTG(date)}
|
|
||||||
</time>
|
|
||||||
)}
|
)}
|
||||||
|
<div class="article-card__body">
|
||||||
|
<Heading class="article-card__title">
|
||||||
|
<a class="article-card__link" href={article.path}>
|
||||||
|
{article.title}
|
||||||
|
</a>
|
||||||
|
</Heading>
|
||||||
|
{excerpt && <p class="article-card__excerpt">{excerpt}</p>}
|
||||||
|
{date && (
|
||||||
|
<time class="article-card__date" datetime={date.toISOString()}>
|
||||||
|
{toMilitaryDTG(date)}
|
||||||
|
</time>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
---
|
---
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string
|
title: string
|
||||||
/** Small label above the title: "Page", "Tag". */
|
|
||||||
eyebrow?: string
|
eyebrow?: string
|
||||||
standfirst?: string
|
standfirst?: string
|
||||||
|
count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, eyebrow, standfirst } = Astro.props
|
const { title, eyebrow, standfirst, count } = Astro.props
|
||||||
---
|
---
|
||||||
|
|
||||||
<header class="header">
|
<header class="simple-header dark">
|
||||||
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
|
<div class="simple-header__inner">
|
||||||
<h1 class="title">{title}</h1>
|
<div class="simple-header__text">
|
||||||
{standfirst && <p class="standfirst">{standfirst}</p>}
|
{eyebrow && <p class="simple-header__eyebrow">{eyebrow}</p>}
|
||||||
<slot />
|
<h1 class="simple-header__title">{title}</h1>
|
||||||
|
{standfirst && <p class="simple-header__standfirst">{standfirst}</p>}
|
||||||
|
</div>
|
||||||
|
{count !== undefined && (
|
||||||
|
<p class="simple-header__count">
|
||||||
|
<span aria-hidden="true">{String(count).padStart(2, '0')}</span>
|
||||||
|
<span class="simple-header__hidden">
|
||||||
|
{count} {count === 1 ? 'entry' : 'entries'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { STAGE_OPTIONS, type Stage } from '@/lib/constants/stage'
|
import { STAGE_OPTIONS, type Stage } from '@/lib/constants/stage'
|
||||||
|
|
||||||
type props = {
|
type Props = {
|
||||||
stage: Stage
|
stage: Stage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const entries = headings.filter((heading) => heading.level <= level)
|
|||||||
@mixin border-t var(--border-card), solid, var(--color-border-strong);
|
@mixin border-t var(--border-card), solid, var(--color-border-strong);
|
||||||
counter-reset: toc;
|
counter-reset: toc;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: var(--size-10);
|
top: calc(var(--layout-header-height) + var(--size-10));
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
|||||||
140
src/components/site/Finder.astro
Normal file
140
src/components/site/Finder.astro
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
---
|
||||||
|
import { COLLECTION_LABELS } from '@/lib/constants/collections'
|
||||||
|
import { toRoman } from '@/lib/content/numerals'
|
||||||
|
import { getSite } from '@/lib/site'
|
||||||
|
import { contextOf } from '@/lib/site/navigation'
|
||||||
|
import type { Article } from '@/lib/site/types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
article?: Article
|
||||||
|
}
|
||||||
|
|
||||||
|
const { article } = Astro.props
|
||||||
|
const site = await getSite()
|
||||||
|
const groups = contextOf(article ?? null, site.roots)
|
||||||
|
const count = groups.reduce((sum, group) => sum + group.entries.length, 0)
|
||||||
|
---
|
||||||
|
|
||||||
|
<site-finder class="finder">
|
||||||
|
<button
|
||||||
|
class="finder__trigger light"
|
||||||
|
type="button"
|
||||||
|
commandfor="finder"
|
||||||
|
command="show-modal"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
>
|
||||||
|
<span class="finder__prompt" aria-hidden="true">
|
||||||
|
❯
|
||||||
|
</span>
|
||||||
|
<span class="finder__placeholder">Navigate…</span>
|
||||||
|
<kbd class="finder__key" data-finder-shortcut>
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<dialog id="finder" class="finder__dialog" aria-labelledby="finder-title" closedby="any">
|
||||||
|
<div class="finder__bar">
|
||||||
|
<h2 id="finder-title" class="finder__title">
|
||||||
|
Register<span class="finder__crumb">Query</span>
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
class="finder__close"
|
||||||
|
type="button"
|
||||||
|
commandfor="finder"
|
||||||
|
command="close"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
Esc
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="finder__field">
|
||||||
|
<span class="finder__prompt" aria-hidden="true">
|
||||||
|
❯
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
class="finder__input"
|
||||||
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-label="Search articles"
|
||||||
|
aria-controls="finder-results"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
placeholder="Where do you want to go?"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
data-finder-input
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="finder__status">
|
||||||
|
<p class="finder__scope" data-finder-scope>
|
||||||
|
<span data-finder-mode>Context</span>
|
||||||
|
<span class="finder__crumb" data-finder-subject>
|
||||||
|
{article?.title ?? 'Index'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="finder__count" data-finder-count>
|
||||||
|
{String(count).padStart(2, '0')} entries
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="finder-results" class="finder__results" role="listbox" aria-label="Articles">
|
||||||
|
<div data-finder-context>
|
||||||
|
{groups.map((group, g) => {
|
||||||
|
const heading = group.heading?.title ?? 'Index'
|
||||||
|
return (
|
||||||
|
<div class="finder__group" role="group" aria-label={heading}>
|
||||||
|
<p class="finder__heading" aria-hidden="true">
|
||||||
|
<span class="finder__section">§ {toRoman(g + 1)}</span>
|
||||||
|
{heading}
|
||||||
|
</p>
|
||||||
|
{group.entries.map((entry, i) => (
|
||||||
|
<a
|
||||||
|
id={`finder-option-${g}-${i}`}
|
||||||
|
class="finder__row"
|
||||||
|
href={entry.path}
|
||||||
|
role="option"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<span class="finder__type">{COLLECTION_LABELS[entry.collection]}</span>
|
||||||
|
<span class="finder__label">{entry.title}</span>
|
||||||
|
<span class="finder__path">{entry.path}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div data-finder-matches hidden></div>
|
||||||
|
<p class="finder__empty" data-finder-empty hidden>
|
||||||
|
No entries found
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template data-finder-row>
|
||||||
|
<a class="finder__row" role="option" tabindex="-1">
|
||||||
|
<span class="finder__type" data-field="type"></span>
|
||||||
|
<span class="finder__label" data-field="label"></span>
|
||||||
|
<span class="finder__path" data-field="path"></span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="finder__footer" aria-hidden="true">
|
||||||
|
<span class="finder__hint">
|
||||||
|
<kbd>↑</kbd>
|
||||||
|
<kbd>↓</kbd> Move
|
||||||
|
</span>
|
||||||
|
<span class="finder__hint">
|
||||||
|
<kbd>↵</kbd> Open
|
||||||
|
</span>
|
||||||
|
<span class="finder__hint">
|
||||||
|
<kbd data-finder-shortcut>⌘K</kbd> Toggle
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</site-finder>
|
||||||
|
<script>
|
||||||
|
import '@/lib/finder/element'
|
||||||
|
</script>
|
||||||
39
src/components/site/SiteFooter.astro
Normal file
39
src/components/site/SiteFooter.astro
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
import { toMilitaryDTG } from '@/lib/content/dates'
|
||||||
|
import { getSite } from '@/lib/site'
|
||||||
|
|
||||||
|
const LINKED_PAGES = ['imprint', 'privacy-policy', 'colophon']
|
||||||
|
|
||||||
|
const site = await getSite()
|
||||||
|
const release = site.releases[0]
|
||||||
|
const pages = LINKED_PAGES.map((id) => site.pages.get(id)).filter((page) => page !== undefined)
|
||||||
|
const year = new Date().getUTCFullYear()
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer class="site-footer dark">
|
||||||
|
<div class="site-footer__inner">
|
||||||
|
<p class="site-footer__copyright">© {year} Dave Damage</p>
|
||||||
|
{release && (
|
||||||
|
<p class="site-footer__release">
|
||||||
|
<span class="site-footer__version">v{release.version}</span>
|
||||||
|
<time datetime={release.date.toISOString()}>{toMilitaryDTG(release.date)}</time>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<nav class="site-footer__nav" aria-label="Site">
|
||||||
|
<ul class="site-footer__links">
|
||||||
|
{pages.map((page) => (
|
||||||
|
<li>
|
||||||
|
<a class="site-footer__link" href={page.path}>
|
||||||
|
{page.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li>
|
||||||
|
<a class="site-footer__link site-footer__top" href="#top">
|
||||||
|
Top
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
26
src/components/site/SiteHeader.astro
Normal file
26
src/components/site/SiteHeader.astro
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
import type { Article } from '@/lib/site/types'
|
||||||
|
|
||||||
|
import Finder from './Finder.astro'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
article?: Article
|
||||||
|
}
|
||||||
|
|
||||||
|
const { article } = Astro.props
|
||||||
|
---
|
||||||
|
|
||||||
|
<header class="site-header dark">
|
||||||
|
<div class="site-header__inner">
|
||||||
|
<a class="site-header__home" href="/" aria-label="dave dmg, home">
|
||||||
|
<span class="site-header__logo" aria-hidden="true">
|
||||||
|
◬
|
||||||
|
</span>
|
||||||
|
<span class="site-header__name">
|
||||||
|
dave<span class="site-header__bracket">[</span>dmg
|
||||||
|
<span class="site-header__bracket">]</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<Finder article={article} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
15
src/content/general/articles/chainbreaker.mdoc
Normal file
15
src/content/general/articles/chainbreaker.mdoc
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
title: Chainbreaker
|
||||||
|
subtitle: We're gonna break down the walls
|
||||||
|
status: draft
|
||||||
|
noindex: false
|
||||||
|
stage: stub
|
||||||
|
route:
|
||||||
|
sequence: false
|
||||||
|
summary:
|
||||||
|
cover:
|
||||||
|
effect: none
|
||||||
|
ratio: original
|
||||||
|
published: 2026-09-30T14:27:00.000Z
|
||||||
|
tags: []
|
||||||
|
---
|
||||||
7
src/content/general/pages/colophon.mdoc
Normal file
7
src/content/general/pages/colophon.mdoc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
title: Colophon
|
||||||
|
status: draft
|
||||||
|
noindex: false
|
||||||
|
route: {}
|
||||||
|
summary: {}
|
||||||
|
---
|
||||||
7
src/content/general/pages/privacy-policy.mdoc
Normal file
7
src/content/general/pages/privacy-policy.mdoc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
title: Privacy Policy
|
||||||
|
status: draft
|
||||||
|
noindex: false
|
||||||
|
route: {}
|
||||||
|
summary: {}
|
||||||
|
---
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
---
|
---
|
||||||
import { Font } from 'astro:assets'
|
import { Font } from 'astro:assets'
|
||||||
|
|
||||||
|
import type { Article } from '@/lib/site/types'
|
||||||
|
|
||||||
import '@/styles/global.css'
|
import '@/styles/global.css'
|
||||||
|
|
||||||
|
import SiteFooter from '@/components/site/SiteFooter.astro'
|
||||||
|
import SiteHeader from '@/components/site/SiteHeader.astro'
|
||||||
import { resolveImage } from '@/lib/images/resolve'
|
import { resolveImage } from '@/lib/images/resolve'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -10,9 +14,10 @@ interface Props {
|
|||||||
description?: string
|
description?: string
|
||||||
image?: string
|
image?: string
|
||||||
noindex?: boolean
|
noindex?: boolean
|
||||||
|
article?: Article
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description, image, noindex } = Astro.props
|
const { title, description, image, noindex, article } = Astro.props
|
||||||
const canonical = new URL(Astro.url.pathname, Astro.site)
|
const canonical = new URL(Astro.url.pathname, Astro.site)
|
||||||
const preview = image ? resolveImage(image) : undefined
|
const preview = image ? resolveImage(image) : undefined
|
||||||
const previewUrl = preview ? new URL(preview.src, Astro.site) : undefined
|
const previewUrl = preview ? new URL(preview.src, Astro.site) : undefined
|
||||||
@@ -47,6 +52,10 @@ const previewUrl = preview ? new URL(preview.src, Astro.site) : undefined
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="ui">
|
<body class="ui">
|
||||||
<slot />
|
<SiteHeader article={article} />
|
||||||
|
<main>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const meta = metaOf(article)
|
|||||||
const sequence = sequenceOf(article)
|
const sequence = sequenceOf(article)
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout {...meta}>
|
<BaseLayout {...meta} article={article}>
|
||||||
<article class="article">
|
<article class="article">
|
||||||
<ArticleHeader article={article} />
|
<ArticleHeader article={article} />
|
||||||
<div class="frame">
|
<div class="frame">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import DevBanner from '@/components/dev/DevBanner.astro'
|
import DevBanner from '@/components/dev/DevBanner.astro'
|
||||||
import Node from '@/components/markdoc/Node.astro'
|
import Node from '@/components/markdoc/Node.astro'
|
||||||
|
import SimpleHeader from '@/components/post/Header/SimpleHeader.astro'
|
||||||
import BaseLayout from '@/layouts/BaseLayout.astro'
|
import BaseLayout from '@/layouts/BaseLayout.astro'
|
||||||
import type { Rendered } from '@/lib/markdoc/types'
|
import type { Rendered } from '@/lib/markdoc/types'
|
||||||
import { metaOf } from '@/lib/site/meta'
|
import { metaOf } from '@/lib/site/meta'
|
||||||
@@ -23,9 +24,33 @@ const meta = metaOf(page)
|
|||||||
diagnostics={rendered.diagnostics}
|
diagnostics={rendered.diagnostics}
|
||||||
/>
|
/>
|
||||||
<article class="page">
|
<article class="page">
|
||||||
<h1>{page.title}</h1>
|
<SimpleHeader eyebrow="Page" title={page.title} standfirst={page.subtitle || undefined} />
|
||||||
<div class="prose">
|
<div class="frame">
|
||||||
<Node node={rendered.tree} />
|
<div class="body content prose">
|
||||||
|
<Node node={rendered.tree} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.frame {
|
||||||
|
@mixin layout-wrapper;
|
||||||
|
@mixin my var(--size-12);
|
||||||
|
|
||||||
|
@media (--bp-desktop) {
|
||||||
|
@mixin layout-columns;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@media (--bp-desktop) {
|
||||||
|
grid-column: body-start / body-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -8,17 +8,15 @@ import type { Tag } from '@/lib/site/types'
|
|||||||
type Props = { tag: Tag }
|
type Props = { tag: Tag }
|
||||||
|
|
||||||
const { tag } = Astro.props
|
const { tag } = Astro.props
|
||||||
const count = tag.articles.length
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`Tagged: ${tag.title}`} description={tag.description || undefined}>
|
<BaseLayout title={`Tagged: ${tag.title}`} description={tag.description || undefined}>
|
||||||
<DevBanner collection={tag.collection} id={tag.id} />
|
<DevBanner collection={tag.collection} id={tag.id} />
|
||||||
<section class="tag">
|
<SimpleHeader
|
||||||
<SimpleHeader eyebrow="Tag" title={tag.title} standfirst={tag.description || undefined}>
|
eyebrow="Tag"
|
||||||
<p class="count">
|
title={tag.title}
|
||||||
{count} {count === 1 ? 'article' : 'articles'}
|
standfirst={tag.description || undefined}
|
||||||
</p>
|
count={tag.articles.length}
|
||||||
</SimpleHeader>
|
/>
|
||||||
<ArticleCardGrid articles={tag.articles} />
|
<ArticleCardGrid articles={tag.articles} />
|
||||||
</section>
|
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -4,5 +4,11 @@ export const ARTICLES = 'articles'
|
|||||||
export const PAGES = 'pages'
|
export const PAGES = 'pages'
|
||||||
export const TAGS = 'tags'
|
export const TAGS = 'tags'
|
||||||
|
|
||||||
|
export const COLLECTION_LABELS = {
|
||||||
|
[ARTICLES]: 'Article',
|
||||||
|
[PAGES]: 'Page',
|
||||||
|
[TAGS]: 'Tag',
|
||||||
|
} as const
|
||||||
|
|
||||||
/* === SITE COLLECTIONS === */
|
/* === SITE COLLECTIONS === */
|
||||||
export const RELEASES = 'releases'
|
export const RELEASES = 'releases'
|
||||||
|
|||||||
@@ -19,18 +19,21 @@ async function readAll<T>(
|
|||||||
collection: string,
|
collection: string,
|
||||||
problems: Diagnostics,
|
problems: Diagnostics,
|
||||||
): Promise<Map<string, T>> {
|
): Promise<Map<string, T>> {
|
||||||
const entries = new Map<string, T>()
|
const ids = (await reader.list()).sort()
|
||||||
await Promise.all(
|
const entries = await Promise.all(
|
||||||
(await reader.list()).map(async (id) => {
|
ids.map(async (id) => {
|
||||||
try {
|
try {
|
||||||
const entry = await reader.read(id)
|
return [id, await reader.read(id)] as const
|
||||||
if (entry) entries.set(id, entry)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
problems.error(collection, id, reason(error))
|
problems.error(collection, id, reason(error))
|
||||||
|
return [id, null] as const
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return entries
|
|
||||||
|
const map = new Map<string, T>()
|
||||||
|
for (const [id, entry] of entries) if (entry) map.set(id, entry)
|
||||||
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadContent(): Promise<Content> {
|
export async function loadContent(): Promise<Content> {
|
||||||
|
|||||||
191
src/lib/finder/element.ts
Normal file
191
src/lib/finder/element.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { ARTICLES, COLLECTION_LABELS } from '@/lib/constants/collections'
|
||||||
|
import { prepare, search, type Prepared } from '@/lib/finder/search'
|
||||||
|
import type { DirectoryEntry } from '@/lib/site/types'
|
||||||
|
|
||||||
|
const LIMIT = 30
|
||||||
|
const INDEX_URL = '/api/site-index.json'
|
||||||
|
|
||||||
|
const pick = <T extends Element>(root: ParentNode, selector: string): T => {
|
||||||
|
const element = root.querySelector<T>(selector)
|
||||||
|
if (!element) throw new Error(`Finder: missing ${selector}`)
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
const countOf = (total: number) => `${String(total).padStart(2, '0')} entries`
|
||||||
|
|
||||||
|
class SiteFinder extends HTMLElement {
|
||||||
|
#dialog!: HTMLDialogElement
|
||||||
|
#input!: HTMLInputElement
|
||||||
|
#results!: HTMLElement
|
||||||
|
#context!: HTMLElement
|
||||||
|
#matches!: HTMLElement
|
||||||
|
#empty!: HTMLElement
|
||||||
|
#mode!: HTMLElement
|
||||||
|
#subject!: HTMLElement
|
||||||
|
#count!: HTMLElement
|
||||||
|
#row!: HTMLTemplateElement
|
||||||
|
|
||||||
|
#index: Promise<Prepared<DirectoryEntry>[]> | null = null
|
||||||
|
#active = -1
|
||||||
|
#initial = { subject: '', count: '' }
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.#dialog = pick(this, 'dialog')
|
||||||
|
this.#input = pick(this, '[data-finder-input]')
|
||||||
|
this.#results = pick(this, '[role="listbox"]')
|
||||||
|
this.#context = pick(this, '[data-finder-context]')
|
||||||
|
this.#matches = pick(this, '[data-finder-matches]')
|
||||||
|
this.#empty = pick(this, '[data-finder-empty]')
|
||||||
|
this.#mode = pick(this, '[data-finder-mode]')
|
||||||
|
this.#subject = pick(this, '[data-finder-subject]')
|
||||||
|
this.#count = pick(this, '[data-finder-count]')
|
||||||
|
this.#row = pick(this, 'template[data-finder-row]')
|
||||||
|
this.#initial = {
|
||||||
|
subject: this.#subject.textContent ?? '',
|
||||||
|
count: this.#count.textContent ?? '',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/Mac|iPhone|iPad/.test(navigator.userAgent))
|
||||||
|
for (const key of this.querySelectorAll('[data-finder-shortcut]'))
|
||||||
|
key.textContent = 'Ctrl K'
|
||||||
|
|
||||||
|
document.addEventListener('keydown', this.#onShortcut)
|
||||||
|
this.#input.addEventListener('input', this.#update)
|
||||||
|
this.#input.addEventListener('keydown', this.#onKey)
|
||||||
|
this.#results.addEventListener('pointermove', this.#onPointer)
|
||||||
|
this.#dialog.addEventListener('click', this.#onBackdrop)
|
||||||
|
this.#dialog.addEventListener('close', this.#reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
document.removeEventListener('keydown', this.#onShortcut)
|
||||||
|
}
|
||||||
|
|
||||||
|
#onShortcut = (event: KeyboardEvent) => {
|
||||||
|
if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey)) return
|
||||||
|
event.preventDefault()
|
||||||
|
if (this.#dialog.open) this.#dialog.close()
|
||||||
|
else this.#dialog.showModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
#onBackdrop = (event: MouseEvent) => {
|
||||||
|
if (event.target !== this.#dialog) return
|
||||||
|
const box = this.#dialog.getBoundingClientRect()
|
||||||
|
const inside =
|
||||||
|
event.clientX >= box.left &&
|
||||||
|
event.clientX <= box.right &&
|
||||||
|
event.clientY >= box.top &&
|
||||||
|
event.clientY <= box.bottom
|
||||||
|
if (!inside) this.#dialog.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
#onKey = (event: KeyboardEvent) => {
|
||||||
|
const options = this.#options()
|
||||||
|
if (!options.length) return
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
this.#select((this.#active + 1) % options.length)
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
this.#select(this.#active <= 0 ? options.length - 1 : this.#active - 1)
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
const option =
|
||||||
|
options[this.#active] ?? (this.#input.value.trim() ? options[0] : undefined)
|
||||||
|
if (!option) return
|
||||||
|
event.preventDefault()
|
||||||
|
option.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#onPointer = (event: PointerEvent) => {
|
||||||
|
const option = (event.target as Element).closest<HTMLAnchorElement>('[role="option"]')
|
||||||
|
if (!option) return
|
||||||
|
const index = this.#options().indexOf(option)
|
||||||
|
if (index !== this.#active) this.#select(index, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#update = async () => {
|
||||||
|
const query = this.#input.value.trim()
|
||||||
|
this.#select(-1)
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
this.#show(false)
|
||||||
|
this.#mode.textContent = 'Context'
|
||||||
|
this.#subject.textContent = this.#initial.subject
|
||||||
|
this.#count.textContent = this.#initial.count
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await this.#load()
|
||||||
|
if (this.#input.value.trim() !== query) return
|
||||||
|
|
||||||
|
const found = search(items, query, LIMIT)
|
||||||
|
this.#matches.replaceChildren(...found.map((entry, i) => this.#render(entry, i)))
|
||||||
|
this.#empty.hidden = found.length > 0
|
||||||
|
this.#show(true)
|
||||||
|
this.#mode.textContent = 'Query'
|
||||||
|
this.#subject.textContent = query
|
||||||
|
this.#count.textContent = countOf(found.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
#reset = () => {
|
||||||
|
this.#input.value = ''
|
||||||
|
void this.#update()
|
||||||
|
}
|
||||||
|
|
||||||
|
#load() {
|
||||||
|
this.#index ??= fetch(INDEX_URL)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error(response.statusText)
|
||||||
|
return response.json() as Promise<DirectoryEntry[]>
|
||||||
|
})
|
||||||
|
.then((entries) => prepare(entries.filter((entry) => entry.collection === ARTICLES)))
|
||||||
|
.catch(() => {
|
||||||
|
this.#index = null
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
return this.#index
|
||||||
|
}
|
||||||
|
|
||||||
|
#show(searching: boolean) {
|
||||||
|
this.#context.hidden = searching
|
||||||
|
this.#matches.hidden = !searching
|
||||||
|
if (!searching) this.#empty.hidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
#options(): HTMLAnchorElement[] {
|
||||||
|
const visible = this.#matches.hidden ? this.#context : this.#matches
|
||||||
|
return [...visible.querySelectorAll<HTMLAnchorElement>('[role="option"]')]
|
||||||
|
}
|
||||||
|
|
||||||
|
#select(index: number, scroll = true) {
|
||||||
|
for (const option of this.#results.querySelectorAll('[aria-selected]'))
|
||||||
|
option.removeAttribute('aria-selected')
|
||||||
|
|
||||||
|
this.#active = index
|
||||||
|
const option = this.#options()[index]
|
||||||
|
if (!option) {
|
||||||
|
this.#input.removeAttribute('aria-activedescendant')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
option.setAttribute('aria-selected', 'true')
|
||||||
|
this.#input.setAttribute('aria-activedescendant', option.id)
|
||||||
|
if (scroll) option.scrollIntoView({ block: 'nearest' })
|
||||||
|
}
|
||||||
|
|
||||||
|
#render(entry: DirectoryEntry, index: number) {
|
||||||
|
const row = pick<HTMLAnchorElement>(this.#row.content, 'a').cloneNode(
|
||||||
|
true,
|
||||||
|
) as HTMLAnchorElement
|
||||||
|
row.id = `finder-match-${index}`
|
||||||
|
row.href = entry.path
|
||||||
|
pick(row, '[data-field="type"]').textContent = COLLECTION_LABELS[entry.collection]
|
||||||
|
pick(row, '[data-field="label"]').textContent = entry.title
|
||||||
|
pick(row, '[data-field="path"]').textContent = entry.path
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get('site-finder')) customElements.define('site-finder', SiteFinder)
|
||||||
61
src/lib/finder/search.ts
Normal file
61
src/lib/finder/search.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { slugify } from '@/lib/content/slug'
|
||||||
|
|
||||||
|
type Searchable = {
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Prepared<T extends Searchable> = {
|
||||||
|
entry: T
|
||||||
|
title: string
|
||||||
|
words: string[]
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const termsOf = (text: string) => slugify(text).split('-').filter(Boolean)
|
||||||
|
|
||||||
|
export function prepare<T extends Searchable>(entries: T[]): Prepared<T>[] {
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
entry,
|
||||||
|
title: slugify(entry.title),
|
||||||
|
words: termsOf(entry.title),
|
||||||
|
path: entry.path.toLowerCase(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreTerm(item: Prepared<Searchable>, term: string): number {
|
||||||
|
if (item.words.includes(term)) return 80
|
||||||
|
if (item.words.some((word) => word.startsWith(term))) return 60
|
||||||
|
if (item.title.includes(term)) return 40
|
||||||
|
if (item.path.includes(term)) return 10
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function search<T extends Searchable>(
|
||||||
|
items: Prepared<T>[],
|
||||||
|
query: string,
|
||||||
|
limit: number,
|
||||||
|
): T[] {
|
||||||
|
const terms = termsOf(query)
|
||||||
|
if (!terms.length) return []
|
||||||
|
const exact = terms.join('-')
|
||||||
|
|
||||||
|
const scored: { entry: T; score: number }[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
let score = item.title === exact ? 100 : 0
|
||||||
|
for (const term of terms) {
|
||||||
|
const points = scoreTerm(item, term)
|
||||||
|
if (!points) {
|
||||||
|
score = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
score += points
|
||||||
|
}
|
||||||
|
if (score) scored.push({ entry: item.entry, score })
|
||||||
|
}
|
||||||
|
|
||||||
|
return scored
|
||||||
|
.sort((a, b) => b.score - a.score || a.entry.title.localeCompare(b.entry.title))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(({ entry }) => entry)
|
||||||
|
}
|
||||||
@@ -30,3 +30,23 @@ export function trailOf(article: Article): Article[] {
|
|||||||
for (let node: Article | null = article; node; node = node.parent) trail.unshift(node)
|
for (let node: Article | null = article; node; node = node.parent) trail.unshift(node)
|
||||||
return trail
|
return trail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ContextGroup = {
|
||||||
|
heading: Article | null
|
||||||
|
entries: Article[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contextOf(article: Article | null, roots: Article[]): ContextGroup[] {
|
||||||
|
if (!article) return [{ heading: null, entries: roots }]
|
||||||
|
|
||||||
|
const { parent } = article
|
||||||
|
const siblings = (parent ? parent.children : roots).filter((entry) => entry !== article)
|
||||||
|
|
||||||
|
const groups: ContextGroup[] = [
|
||||||
|
{ heading: parent?.parent ?? null, entries: parent?.listed ? [parent] : [] },
|
||||||
|
{ heading: parent, entries: siblings },
|
||||||
|
{ heading: article, entries: article.children },
|
||||||
|
]
|
||||||
|
|
||||||
|
return groups.filter((group) => group.entries.length > 0)
|
||||||
|
}
|
||||||
|
|||||||
39
src/pages/sitemap.xml.ts
Normal file
39
src/pages/sitemap.xml.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
import { ARTICLES } from '@/lib/constants/collections'
|
||||||
|
import { isoDate } from '@/lib/content/dates'
|
||||||
|
import { getSite } from '@/lib/site'
|
||||||
|
import type { Routed } from '@/lib/site/types'
|
||||||
|
|
||||||
|
type Url = {
|
||||||
|
path: string
|
||||||
|
modified: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiedOf = (node: Routed): Date | null =>
|
||||||
|
node.collection === ARTICLES ? (node.changes[0]?.release.date ?? node.publishedAt) : null
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ site: origin }) => {
|
||||||
|
if (!origin) throw new Error('sitemap.xml needs `site` in astro.config.mjs')
|
||||||
|
|
||||||
|
const site = await getSite()
|
||||||
|
const urls: Url[] = [
|
||||||
|
{ path: '/', modified: null },
|
||||||
|
...[...site.byPath.values()]
|
||||||
|
.filter((node) => node.status === 'published' && !node.noindex)
|
||||||
|
.map((node) => ({ path: node.path, modified: modifiedOf(node) })),
|
||||||
|
...[...site.tags.values()].map((tag) => ({ path: tag.path, modified: null })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
...urls.map(({ path, modified }) => {
|
||||||
|
const lastmod = modified ? `<lastmod>${isoDate(modified)}</lastmod>` : ''
|
||||||
|
return ` <url><loc>${new URL(path, origin)}</loc>${lastmod}</url>`
|
||||||
|
}),
|
||||||
|
'</urlset>',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
return new Response(body, { headers: { 'Content-Type': 'application/xml' } })
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@layer tokens {
|
@layer tokens {
|
||||||
:root {
|
:root,
|
||||||
|
.light {
|
||||||
/* === PALETTE === */
|
/* === PALETTE === */
|
||||||
|
|
||||||
/* == Ink == */
|
/* == Ink == */
|
||||||
|
|||||||
@@ -49,7 +49,6 @@
|
|||||||
@mixin typo_body-small;
|
@mixin typo_body-small;
|
||||||
|
|
||||||
counter-increment: fn;
|
counter-increment: fn;
|
||||||
scroll-margin-block-start: calc(var(--layout-header-height) + var(--size-8));
|
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--footnote-marker) minmax(0, 1fr);
|
grid-template-columns: var(--footnote-marker) minmax(0, 1fr);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
@mixin pt var(--size-3);
|
@mixin pt var(--size-3);
|
||||||
@mixin border-t var(--border-card), solid, var(--color-border-strong);
|
@mixin border-t var(--border-card), solid, var(--color-border-strong);
|
||||||
|
|
||||||
scroll-margin-block-start: calc(var(--layout-header-height) + var(--size-8));
|
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
/* Hangs, so the first line starts against the marker. */
|
/* Hangs, so the first line starts against the marker. */
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
@import url("prose/index.css");
|
@import url("prose/index.css");
|
||||||
@import url("blocks/index.css");
|
@import url("blocks/index.css");
|
||||||
@import url("post/index.css");
|
@import url("post/index.css");
|
||||||
|
@import url("site/index.css");
|
||||||
|
|||||||
34
src/styles/components/post/article-card-grid.css
Normal file
34
src/styles/components/post/article-card-grid.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
@layer components.blocks {
|
||||||
|
.article-card-grid {
|
||||||
|
@mixin layout-wrapper;
|
||||||
|
@mixin my var(--size-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card-grid__title {
|
||||||
|
@mixin typo_heading-2;
|
||||||
|
@mixin mt var(--size-0);
|
||||||
|
@mixin mb var(--size-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card-grid__list {
|
||||||
|
@mixin ma var(--size-0);
|
||||||
|
@mixin pa var(--size-0);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: var(--size-10);
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
@media (--bp-tablet-up) {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (--bp-desktop) {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card-grid__item {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/styles/components/post/article-card.css
Normal file
84
src/styles/components/post/article-card.css
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
@layer components.blocks {
|
||||||
|
.article-card {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
border: var(--border-card) solid var(--color-border-strong);
|
||||||
|
|
||||||
|
background-color: var(--color-surface-elevated-1);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
|
||||||
|
transition: box-shadow var(--motion-fast) var(--ease-out);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(.article-card__link:focus-visible) {
|
||||||
|
@mixin focus-ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card__cover {
|
||||||
|
@mixin border-b var(--border-card), solid, var(--color-border-strong);
|
||||||
|
|
||||||
|
& img {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card__body {
|
||||||
|
@mixin pa var(--size-9);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card__title {
|
||||||
|
@mixin typo_heading-2;
|
||||||
|
@mixin ma var(--size-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card__link {
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card__excerpt {
|
||||||
|
@mixin typo_body-small;
|
||||||
|
@mixin ma var(--size-0);
|
||||||
|
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card__date {
|
||||||
|
@mixin typo_tag;
|
||||||
|
@mixin mt auto;
|
||||||
|
@mixin pt var(--size-5);
|
||||||
|
@mixin border-t var(--border-hairline), solid, var(--color-border-subtle);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "❯" / "";
|
||||||
|
margin-inline-end: var(--size-3);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--color-text-quiet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,15 +50,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-header__subtitle {
|
.article-header__subtitle {
|
||||||
|
@mixin typo_standfirst;
|
||||||
@mixin ma var(--size-0);
|
@mixin ma var(--size-0);
|
||||||
|
|
||||||
font-family: var(--font-header);
|
|
||||||
font-size: var(--type-1);
|
|
||||||
font-weight: var(--font-weight-body);
|
|
||||||
line-height: var(--leading-1);
|
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: var(--tracking-subtitle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-header__meta {
|
.article-header__meta {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
@import url("article-card.css");
|
||||||
|
@import url("article-card-grid.css");
|
||||||
@import url("article-header.css");
|
@import url("article-header.css");
|
||||||
@import url("stage-badge.css");
|
|
||||||
@import url("article-footer.css");
|
@import url("article-footer.css");
|
||||||
@import url("sequence-nav.css");
|
|
||||||
@import url("revision-card.css");
|
@import url("revision-card.css");
|
||||||
|
@import url("sequence-nav.css");
|
||||||
|
@import url("simple-header.css");
|
||||||
|
@import url("stage-badge.css");
|
||||||
|
|||||||
87
src/styles/components/post/simple-header.css
Normal file
87
src/styles/components/post/simple-header.css
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
@layer components.blocks {
|
||||||
|
.simple-header {
|
||||||
|
@mixin border-t var(--border-hairline), solid, var(--color-border-subtle);
|
||||||
|
@mixin border-b var(--border-hairline), solid, var(--color-border-normal);
|
||||||
|
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-surface-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header__inner {
|
||||||
|
@mixin layout-wrapper;
|
||||||
|
@mixin py var(--size-14);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
gap: var(--size-10);
|
||||||
|
|
||||||
|
@media (--bp-desktop) {
|
||||||
|
@mixin layout-columns;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header__text {
|
||||||
|
@media (--bp-desktop) {
|
||||||
|
grid-column: body-start / margin-end;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header__eyebrow {
|
||||||
|
@mixin typo_label;
|
||||||
|
@mixin ma var(--size-0);
|
||||||
|
|
||||||
|
color: var(--color-text-quiet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header__title {
|
||||||
|
@mixin mt var(--size-6);
|
||||||
|
@mixin mb var(--size-0);
|
||||||
|
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(var(--type-9), 6vw, var(--size-15));
|
||||||
|
font-weight: var(--font-weight-display);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: var(--tracking-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header__standfirst {
|
||||||
|
@mixin typo_standfirst;
|
||||||
|
@mixin mt var(--size-8);
|
||||||
|
@mixin mb var(--size-0);
|
||||||
|
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header__count {
|
||||||
|
@mixin ma var(--size-0);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
order: -1;
|
||||||
|
|
||||||
|
aspect-ratio: 1;
|
||||||
|
width: var(--size-14);
|
||||||
|
|
||||||
|
font-family: var(--font-header);
|
||||||
|
font-size: var(--type-7);
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-text-on-accent);
|
||||||
|
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
|
||||||
|
@media (--bp-desktop) {
|
||||||
|
grid-column: rail-start / rail-end;
|
||||||
|
grid-row: 1;
|
||||||
|
width: var(--size-16);
|
||||||
|
font-size: var(--type-10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header__hidden {
|
||||||
|
@mixin visually-hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
318
src/styles/components/site/finder.css
Normal file
318
src/styles/components/site/finder.css
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
@layer components.blocks {
|
||||||
|
.finder {
|
||||||
|
display: block;
|
||||||
|
flex: 0 1 22rem;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
& kbd {
|
||||||
|
@mixin typo_tag;
|
||||||
|
@mixin py var(--size-1);
|
||||||
|
@mixin px var(--size-3);
|
||||||
|
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
background-color: var(--color-surface-inverse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === TRIGGER === */
|
||||||
|
|
||||||
|
.finder__trigger {
|
||||||
|
@mixin py var(--size-3);
|
||||||
|
@mixin ps var(--size-5);
|
||||||
|
@mixin pe var(--size-3);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
gap: var(--size-5);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
border: var(--border-hairline) solid var(--color-border-subtle);
|
||||||
|
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--type-0);
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-text-quiet);
|
||||||
|
|
||||||
|
background-color: var(--color-surface-elevated-1);
|
||||||
|
|
||||||
|
transition:
|
||||||
|
color var(--motion-fast) var(--ease-out),
|
||||||
|
border-color var(--motion-fast) var(--ease-out);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-border-normal);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@mixin focus-ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__prompt {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__placeholder {
|
||||||
|
flex: 1;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__key {
|
||||||
|
@media (--bp-mobile) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === DIALOG === */
|
||||||
|
|
||||||
|
.finder__dialog {
|
||||||
|
@mixin pa var(--size-0);
|
||||||
|
|
||||||
|
width: min(40rem, 100% - 2 * var(--layout-page-margin));
|
||||||
|
max-width: none;
|
||||||
|
max-height: min(40rem, 76dvh);
|
||||||
|
margin: 12dvh auto auto;
|
||||||
|
border: var(--border-card) solid var(--color-border-strong);
|
||||||
|
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
|
background-color: var(--color-surface-elevated-1);
|
||||||
|
box-shadow: var(--size-4) var(--size-4) 0 var(--color-primary);
|
||||||
|
|
||||||
|
&::backdrop {
|
||||||
|
background-color: var(--color-overlay-heavy);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[open] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__bar {
|
||||||
|
@mixin py var(--size-4);
|
||||||
|
@mixin px var(--size-8);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
gap: var(--size-6);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
|
||||||
|
background-color: var(--color-surface-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__title {
|
||||||
|
@mixin typo_eyebrow;
|
||||||
|
@mixin ma var(--size-0);
|
||||||
|
|
||||||
|
font-weight: var(--font-weight-emphasis);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__crumb {
|
||||||
|
&::before {
|
||||||
|
content: "⟫" / "";
|
||||||
|
margin-inline: var(--size-2);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__close {
|
||||||
|
@mixin typo_tag;
|
||||||
|
@mixin py var(--size-1);
|
||||||
|
@mixin px var(--size-3);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
border: var(--border-hairline) solid currentcolor;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@mixin focus-ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__field {
|
||||||
|
@mixin px var(--size-8);
|
||||||
|
@mixin border-b var(--border-card), solid, var(--color-border-strong);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
gap: var(--size-6);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
& .finder__prompt {
|
||||||
|
font-size: var(--type-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__input {
|
||||||
|
@mixin py var(--size-8);
|
||||||
|
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--type-3);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-text-quiet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__status {
|
||||||
|
@mixin py var(--size-5);
|
||||||
|
@mixin px var(--size-8);
|
||||||
|
@mixin border-b var(--border-hairline), solid, var(--color-border-normal);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
gap: var(--size-6);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__scope,
|
||||||
|
.finder__count {
|
||||||
|
@mixin typo_eyebrow;
|
||||||
|
@mixin ma var(--size-0);
|
||||||
|
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === RESULTS === */
|
||||||
|
|
||||||
|
.finder__results {
|
||||||
|
@mixin px var(--size-8);
|
||||||
|
@mixin pb var(--size-6);
|
||||||
|
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__heading {
|
||||||
|
@mixin typo_eyebrow;
|
||||||
|
@mixin mt var(--size-8);
|
||||||
|
@mixin mb var(--size-0);
|
||||||
|
@mixin pb var(--size-3);
|
||||||
|
@mixin border-b var(--border-card), solid, var(--color-border-strong);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
gap: var(--size-6);
|
||||||
|
font-weight: var(--font-weight-emphasis);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__section {
|
||||||
|
font-weight: var(--font-weight-body);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__row {
|
||||||
|
@mixin mx calc(-1 * var(--size-8));
|
||||||
|
@mixin py var(--size-6);
|
||||||
|
@mixin px var(--size-8);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: var(--size-8);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "❯" / "";
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--color-text-quiet);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-selected="true"] {
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
background-color: var(--color-surface-inverse);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .finder__type,
|
||||||
|
& .finder__path {
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (--bp-tablet-up) {
|
||||||
|
grid-template-columns: 6rem minmax(0, 1fr) minmax(0, 14rem) auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__type {
|
||||||
|
@mixin typo_eyebrow;
|
||||||
|
|
||||||
|
color: var(--color-text-quiet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__label {
|
||||||
|
@mixin typo_heading-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__path {
|
||||||
|
overflow: hidden;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--type--1);
|
||||||
|
color: var(--color-text-quiet);
|
||||||
|
text-align: end;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@media (--bp-tablet-up) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__empty {
|
||||||
|
@mixin typo_eyebrow;
|
||||||
|
@mixin ma var(--size-0);
|
||||||
|
@mixin py var(--size-12);
|
||||||
|
@mixin px var(--size-8);
|
||||||
|
|
||||||
|
color: var(--color-text-quiet);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === FOOTER === */
|
||||||
|
|
||||||
|
.finder__footer {
|
||||||
|
@mixin typo_eyebrow;
|
||||||
|
@mixin py var(--size-5);
|
||||||
|
@mixin px var(--size-8);
|
||||||
|
@mixin border-t var(--border-card), solid, var(--color-border-strong);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
gap: var(--size-8);
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
|
@media (--bp-mobile) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder__hint {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: var(--size-2);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/styles/components/site/index.css
Normal file
3
src/styles/components/site/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@import url("site-header.css");
|
||||||
|
@import url("finder.css");
|
||||||
|
@import url("site-footer.css");
|
||||||
72
src/styles/components/site/site-footer.css
Normal file
72
src/styles/components/site/site-footer.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
@layer components.blocks {
|
||||||
|
.site-footer {
|
||||||
|
@mixin mt var(--size-16);
|
||||||
|
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background-color: var(--color-surface-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__inner {
|
||||||
|
@mixin layout-wrapper;
|
||||||
|
@mixin typo_eyebrow;
|
||||||
|
@mixin py var(--size-8);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--size-6) var(--layout-gutter);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__copyright,
|
||||||
|
.site-footer__release {
|
||||||
|
@mixin ma var(--size-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__version {
|
||||||
|
font-weight: var(--font-weight-emphasis);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "·" / "";
|
||||||
|
margin-inline: var(--size-3);
|
||||||
|
font-weight: var(--font-weight-body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__links {
|
||||||
|
@mixin ma var(--size-0);
|
||||||
|
@mixin pa var(--size-0);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--size-2) var(--size-3);
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
& li + li {
|
||||||
|
&::before {
|
||||||
|
content: "·" / "";
|
||||||
|
margin-inline-end: var(--size-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__link {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@mixin focus-ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__top {
|
||||||
|
&::before {
|
||||||
|
content: "↑" / "";
|
||||||
|
margin-inline-end: var(--size-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/styles/components/site/site-header.css
Normal file
111
src/styles/components/site/site-header.css
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
@layer components.blocks {
|
||||||
|
html {
|
||||||
|
scroll-padding-block-start: calc(var(--layout-header-height) + var(--size-8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 1;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
|
background-color: var(--color-surface-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__inner {
|
||||||
|
@mixin layout-wrapper;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
gap: var(--layout-gutter);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
height: var(--layout-header-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__home {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: var(--size-4);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--type-1);
|
||||||
|
line-height: var(--leading-1);
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@mixin focus-ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__logo {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
animation:
|
||||||
|
site-header-pulse 5s cubic-bezier(0.4, 0, 0.6, 1) infinite,
|
||||||
|
site-header-glitch 13s step-end infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__bracket {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes site-header-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
25%,
|
||||||
|
75% {
|
||||||
|
opacity: 0.66;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.33;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes site-header-glitch {
|
||||||
|
0%,
|
||||||
|
84%,
|
||||||
|
91%,
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
85% {
|
||||||
|
transform: translate(-2px, 0);
|
||||||
|
filter: brightness(0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
86% {
|
||||||
|
transform: translate(1px, -1px);
|
||||||
|
filter: brightness(0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
87% {
|
||||||
|
transform: translate(-1px, 1px);
|
||||||
|
filter: brightness(0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
88% {
|
||||||
|
transform: translate(2px, 0);
|
||||||
|
filter: brightness(0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
89% {
|
||||||
|
transform: translate(-2px, 1px);
|
||||||
|
filter: brightness(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
90% {
|
||||||
|
transform: translate(0, -1px);
|
||||||
|
filter: brightness(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,3 +23,19 @@
|
|||||||
[body-end margin-start] var(--layout-margin-width) [margin-end];
|
[body-end margin-start] var(--layout-margin-width) [margin-end];
|
||||||
column-gap: var(--layout-gutter);
|
column-gap: var(--layout-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@define-mixin visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
clip-path: inset(50%);
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,3 +117,12 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: var(--tracking-tag);
|
letter-spacing: var(--tracking-tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@define-mixin typo_standfirst {
|
||||||
|
font-family: var(--font-header);
|
||||||
|
font-size: var(--type-1);
|
||||||
|
font-weight: var(--font-weight-body);
|
||||||
|
line-height: var(--leading-1);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-subtitle);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user