summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorVolpeon <git@volpeon.ink>2026-07-22 19:40:59 +0200
committerVolpeon <git@volpeon.ink>2026-07-22 19:40:59 +0200
commitc1e28224aa2e1450031669d9a7e359b63e04687d (patch)
tree68f3d8905c763a91f68001d868197effa92adafa
downloadcorvos-website-c1e28224aa2e1450031669d9a7e359b63e04687d.tar.gz
corvos-website-c1e28224aa2e1450031669d9a7e359b63e04687d.tar.bz2
corvos-website-c1e28224aa2e1450031669d9a7e359b63e04687d.zip
Init
-rw-r--r--.gitignore3
-rw-r--r--.oxfmtrc.json20
-rw-r--r--.oxlintrc.json12
-rw-r--r--.zed/settings.json184
-rw-r--r--.zed/tasks.json8
-rw-r--r--corvos.config.ts80
-rw-r--r--deno.json84
-rw-r--r--src/assets/InterVariable-Italic.woff2bin0 -> 387976 bytes
-rw-r--r--src/assets/InterVariable.woff2bin0 -> 352240 bytes
-rw-r--r--src/assets/favicon.svg15
-rw-r--r--src/assets/symbols.svg413
-rw-r--r--src/data/nav.ts9
-rw-r--r--src/deps/strapi.tsx416
-rw-r--r--src/pages/index.mdx10
-rw-r--r--src/pages/index.tsx64
-rw-r--r--src/pages/pkg/corvos@[version].ts35
-rw-r--r--src/pages/releases/[version].tsx100
-rw-r--r--src/pages/releases/feed.ts19
-rw-r--r--src/pages/releases/index.mdx8
-rw-r--r--src/pages/releases/index.tsx51
-rw-r--r--src/pages/sitemap.ts8
-rw-r--r--src/schemas/misc.ts16
-rw-r--r--src/schemas/mod.ts8
-rw-r--r--src/schemas/releases.ts18
-rw-r--r--src/scripts/lib/dom.ts59
-rw-r--r--src/scripts/lib/keyboardNav.ts272
-rw-r--r--src/scripts/lib/lightbox.ts450
-rw-r--r--src/scripts/lib/siteHeader.ts37
-rw-r--r--src/scripts/lib/tabbar.ts12
-rw-r--r--src/scripts/lib/toc.ts14
-rw-r--r--src/scripts/script.ts17
-rw-r--r--src/styles/_common.scss87
-rw-r--r--src/styles/components/_home-banner.scss39
-rw-r--r--src/styles/components/_logo.scss81
-rw-r--r--src/styles/components/_site-footer.scss157
-rw-r--r--src/styles/components/_site-footer.vars.scss34
-rw-r--r--src/styles/components/_site-header.scss136
-rw-r--r--src/styles/components/_site-header.vars.scss14
-rw-r--r--src/styles/components/_site-nav.scss30
-rw-r--r--src/styles/components/_subtitle.scss11
-rw-r--r--src/styles/components/_visited-indicator.scss27
-rw-r--r--src/styles/objects/_deflist.scss25
-rw-r--r--src/styles/objects/_message.scss216
-rw-r--r--src/styles/objects/_message.vars.scss39
-rw-r--r--src/styles/objects/_placeholder-box.scss12
-rw-r--r--src/styles/objects/_toc.scss27
-rw-r--r--src/styles/style.scss53
-rw-r--r--src/ui/base/BaseLayout.tsx134
-rw-r--r--src/ui/components/Logo.tsx34
-rw-r--r--src/ui/components/ReleaseCard.tsx47
-rw-r--r--src/ui/components/SiteFooter.tsx39
-rw-r--r--src/ui/components/SiteHeader.tsx121
-rw-r--r--src/ui/components/SiteNav.tsx38
-rw-r--r--src/ui/layouts/CardList.tsx52
-rw-r--r--src/ui/layouts/Container.tsx33
-rw-r--r--src/ui/layouts/EmojiGrid.tsx40
-rw-r--r--src/ui/layouts/HList.tsx44
-rw-r--r--src/ui/layouts/SplitView.tsx27
-rw-r--r--src/ui/layouts/SplitViewPanel.tsx33
-rw-r--r--src/ui/objects/ActionButton.tsx61
-rw-r--r--src/ui/objects/Alert.tsx21
-rw-r--r--src/ui/objects/Avatar.tsx69
-rw-r--r--src/ui/objects/Badge.tsx19
-rw-r--r--src/ui/objects/Body.tsx21
-rw-r--r--src/ui/objects/Button.tsx26
-rw-r--r--src/ui/objects/Card.tsx47
-rw-r--r--src/ui/objects/CardBody.tsx32
-rw-r--r--src/ui/objects/CardFooter.tsx11
-rw-r--r--src/ui/objects/CardImage.tsx40
-rw-r--r--src/ui/objects/CollapseCard.tsx27
-rw-r--r--src/ui/objects/ContentImage.tsx15
-rw-r--r--src/ui/objects/Deflist.tsx7
-rw-r--r--src/ui/objects/Dialog.tsx41
-rw-r--r--src/ui/objects/Divider.tsx51
-rw-r--r--src/ui/objects/Emoji.tsx21
-rw-r--r--src/ui/objects/Figure.tsx13
-rw-r--r--src/ui/objects/FigureCaption.tsx11
-rw-r--r--src/ui/objects/Heading.tsx49
-rw-r--r--src/ui/objects/Icon.tsx20
-rw-r--r--src/ui/objects/Lightbox.tsx122
-rw-r--r--src/ui/objects/LoadingIndicator.tsx10
-rw-r--r--src/ui/objects/Navbar.tsx37
-rw-r--r--src/ui/objects/NavbarItem.tsx33
-rw-r--r--src/ui/objects/PlaceholderBox.tsx11
-rw-r--r--src/ui/objects/SideNav.tsx14
-rw-r--r--src/ui/objects/SideNavItem.tsx33
-rw-r--r--src/ui/objects/StatusIndicator.tsx18
-rw-r--r--src/ui/objects/Tab.tsx14
-rw-r--r--src/ui/objects/Tabbar.tsx43
-rw-r--r--src/ui/objects/TextField.tsx62
-rw-r--r--src/ui/objects/Thumbnail.tsx46
91 files changed, 5117 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ca2f874
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
1site
2cache
3.env
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
new file mode 100644
index 0000000..e480ec8
--- /dev/null
+++ b/.oxfmtrc.json
@@ -0,0 +1,20 @@
1{
2 "$schema": "https://cdn.jsdelivr.net/npm/oxfmt@0.57.0/configuration_schema.json",
3 "ignorePatterns": ["submodules", "cache", "site"],
4 "useTabs": true,
5 "endOfLine": "lf",
6 "sortImports": {
7 "newlinesBetween": true,
8 "groups": [
9 "type-import",
10 ["value-builtin", "value-external"],
11 "type-internal",
12 "value-internal",
13 ["type-parent", "type-sibling", "type-index"],
14 ["value-parent", "value-sibling", "value-index"],
15 "unknown"
16 ],
17 "internalPattern": ["~/", "@/", "$/", "#"]
18 },
19 "quoteProps": "consistent"
20}
diff --git a/.oxlintrc.json b/.oxlintrc.json
new file mode 100644
index 0000000..c4522bb
--- /dev/null
+++ b/.oxlintrc.json
@@ -0,0 +1,12 @@
1{
2 "$schema": "https://cdn.jsdelivr.net/npm/oxlint@1.73.0/configuration_schema.json",
3 "ignorePatterns": [".zed", ".vscode", "submodules", "cache", "site", "*.scss"],
4 "plugins": ["eslint", "typescript", "unicorn", "oxc", "import"],
5 "categories": {
6 "correctness": "error"
7 },
8 "rules": {
9 "typescript/consistent-type-imports": "error",
10 "typescript/consistent-type-definitions": "error"
11 }
12}
diff --git a/.zed/settings.json b/.zed/settings.json
new file mode 100644
index 0000000..3013fe6
--- /dev/null
+++ b/.zed/settings.json
@@ -0,0 +1,184 @@
1// Folder-specific settings
2//
3// For a full list of overridable settings, and general information on folder-specific settings,
4// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
5{
6 "languages": {
7 "MDX": {
8 "format_on_save": "on",
9 "prettier": {
10 "allowed": false
11 },
12 "formatter": [
13 {
14 "code_action": "source.fixAll.oxc"
15 },
16 {
17 "language_server": {
18 "name": "oxfmt"
19 }
20 }
21 ]
22 },
23 "JavaScript": {
24 "language_servers": [
25 "deno",
26 "oxfmt",
27 "!typescript-language-server",
28 "!vtsls",
29 "!eslint",
30 "!codebook",
31 "..."
32 ],
33 "format_on_save": "on",
34 "prettier": {
35 "allowed": false
36 },
37 "formatter": [
38 {
39 "code_action": "source.fixAll.oxc"
40 },
41 {
42 "language_server": {
43 "name": "oxfmt"
44 }
45 }
46 ]
47 },
48 "TypeScript": {
49 "language_servers": [
50 "deno",
51 "oxfmt",
52 "!typescript-language-server",
53 "!vtsls",
54 "!eslint",
55 "!codebook",
56 "..."
57 ],
58 "format_on_save": "on",
59 "prettier": {
60 "allowed": false
61 },
62 "formatter": [
63 {
64 "code_action": "source.fixAll.oxc"
65 },
66 {
67 "language_server": {
68 "name": "oxfmt"
69 }
70 }
71 ]
72 },
73 "TSX": {
74 "language_servers": [
75 "deno",
76 "oxfmt",
77 "!typescript-language-server",
78 "!vtsls",
79 "!eslint",
80 "!codebook",
81 "..."
82 ],
83 "format_on_save": "on",
84 "prettier": {
85 "allowed": false
86 },
87 "formatter": [
88 {
89 "code_action": "source.fixAll.oxc"
90 },
91 {
92 "language_server": {
93 "name": "oxfmt"
94 }
95 }
96 ]
97 },
98 "JSON": {
99 "language_servers": ["!codebook", "..."],
100 "format_on_save": "on",
101 "prettier": {
102 "allowed": false
103 },
104 "formatter": [
105 {
106 "code_action": "source.fixAll.oxc"
107 },
108 {
109 "language_server": {
110 "name": "oxfmt"
111 }
112 }
113 ]
114 },
115 "JSONC": {
116 "format_on_save": "on",
117 "prettier": {
118 "allowed": false
119 },
120 "formatter": [
121 {
122 "code_action": "source.fixAll.oxc"
123 },
124 {
125 "language_server": {
126 "name": "oxfmt"
127 }
128 }
129 ]
130 },
131 "SCSS": {
132 "format_on_save": "on",
133 "prettier": {
134 "allowed": false
135 },
136 "formatter": [
137 {
138 "code_action": "source.fixAll.oxc"
139 },
140 {
141 "language_server": {
142 "name": "oxfmt"
143 }
144 }
145 ]
146 }
147 },
148 "lsp": {
149 "deno": {
150 "settings": {
151 "deno": {
152 "enable": true
153 }
154 }
155 },
156 "oxlint": {
157 "initialization_options": {
158 "settings": {
159 "configPath": null,
160 "run": "onType",
161 "disableNestedConfig": false,
162 "fixKind": "safe_fix",
163 "typeAware": true,
164 "unusedDisableDirectives": "deny"
165 }
166 }
167 },
168 "oxfmt": {
169 "initialization_options": {
170 "settings": {
171 "fmt.configPath": null,
172 "run": "onSave"
173 }
174 }
175 },
176 "mdx-analyzer": {
177 "initialization_options": {
178 "typescript": {
179 "enabled": true
180 }
181 }
182 }
183 }
184}
diff --git a/.zed/tasks.json b/.zed/tasks.json
new file mode 100644
index 0000000..b706900
--- /dev/null
+++ b/.zed/tasks.json
@@ -0,0 +1,8 @@
1// Project tasks configuration. See https://zed.dev/docs/tasks for documentation.
2[
3 {
4 "label": "Serve",
5 "command": "deno",
6 "args": ["run", "serve"]
7 }
8]
diff --git a/corvos.config.ts b/corvos.config.ts
new file mode 100644
index 0000000..deae101
--- /dev/null
+++ b/corvos.config.ts
@@ -0,0 +1,80 @@
1import "https://deno.land/std@0.224.0/dotenv/load.ts";
2import type { Page } from "corvos";
3
4import { Pipeline, Site } from "corvos";
5import { assets } from "corvos/plugins/assets.ts";
6import { brotli } from "corvos/plugins/brotli.ts";
7import { cache } from "corvos/plugins/cache.ts";
8import { esbuild } from "corvos/plugins/esbuild.ts";
9import { feeds } from "corvos/plugins/feeds.ts";
10import { gzip } from "corvos/plugins/gzip.ts";
11import { hash } from "corvos/plugins/hash.ts";
12import { icons } from "corvos/plugins/icons.ts";
13import { images } from "corvos/plugins/images.ts";
14import { jsx } from "corvos/plugins/jsx.ts";
15import { markdown } from "corvos/plugins/markdown.ts";
16import { mdx } from "corvos/plugins/mdx.ts";
17import { modules } from "corvos/plugins/modules.ts";
18import { queryBuilder } from "corvos/plugins/query_builder.ts";
19import { sass } from "corvos/plugins/sass.ts";
20import { sitemap } from "corvos/plugins/sitemap.ts";
21import { templates } from "corvos/plugins/templates.ts";
22import { transformImages } from "corvos/plugins/transform_images.ts";
23import { urls } from "corvos/plugins/urls.ts";
24
25import type { Node } from "$/deps/strapi.tsx";
26import type { PageData } from "$/schemas/mod.ts";
27
28import { strapi } from "$/deps/strapi.tsx";
29import { pageDataSchema } from "$/schemas/mod.ts";
30
31const assetPipeline = Pipeline.create()
32 .use(cache())
33
34 .use(assets())
35 .use(hash())
36
37 .use(esbuild())
38 .use(sass())
39 .use(icons())
40 .use(
41 images({
42 allowedOrigins: ["strapi.volpeon.ink"],
43 defaults: { formats: ["avif", "webp", "png"], densities: [1, 2] },
44 }),
45 )
46 .use(transformImages())
47
48 .use(brotli())
49 .use(gzip());
50
51const pagePipeline = Pipeline.create()
52 // Helpers
53 .use(queryBuilder())
54
55 // Content
56 .use(modules<Page<".strapi", Node[], PageData> | Page<".", Uint8Array<ArrayBuffer>>>())
57 .use(markdown({ frontmatterSchema: pageDataSchema }))
58 .use(mdx({ frontmatterSchema: pageDataSchema }))
59 .use(strapi())
60 .use(jsx())
61 .use(urls({ base: new URL("https://corvos.volpeon.ink/") }))
62
63 // Page listings
64 .use(feeds<{ type: "releases" }>())
65 .use(sitemap())
66
67 // Templates
68 .use(templates())
69 .use(jsx())
70
71 .use(brotli())
72 .use(gzip());
73
74const site = new Site({
75 assetPipeline,
76 pagePipeline,
77 redirects: [[/^\/(scripts|styles)\//, "/assets/"]],
78});
79
80export default site;
diff --git a/deno.json b/deno.json
new file mode 100644
index 0000000..caf143e
--- /dev/null
+++ b/deno.json
@@ -0,0 +1,84 @@
1{
2 "$schema": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json",
3 "imports": {
4 "$/": "./src/",
5 "@/corvos.config.ts": "./corvos.config.ts",
6 "@std/datetime": "jsr:@std/datetime@^0.225.7",
7 "@std/encoding": "jsr:@std/encoding@^1.0.11",
8 "@std/streams": "jsr:@std/streams@^1.1.1",
9 "@types/alpinejs": "npm:@types/alpinejs@^3.13.11",
10 "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.26",
11 "alpinejs": "npm:alpinejs@^3.15.12",
12 "arktype": "npm:arktype@2.2.3",
13 "corvos": "../corvos/mod.ts",
14 "corvos/": "../corvos/",
15 "corvos/jsx-runtime": "../corvos/jsx-runtime.ts",
16 "include-media/": "https://raw.githubusercontent.com/eduardoboucas/include-media/refs/heads/master/",
17 "iro-sass/": "https://git.wyvern.rip/iro-sass/plain/src/",
18 "iro-design/": "https://git.wyvern.rip/iro-design/plain/src/",
19 "mrmime": "npm:mrmime@^2.0.1",
20 "oxfmt": "npm:oxfmt@^0.58.0",
21 "oxlint": "npm:oxlint@^1.73.0"
22 },
23 "permissions": {
24 "corvos": {
25 "read": true,
26 "write": ["./"],
27 "import": [
28 "cdn.jsdelivr.net:443",
29 "jsr.io:443",
30 "deno.land:443",
31 "esm.sh:443",
32 "corvos.wyvern.rip:443"
33 ],
34 "net": [
35 "0.0.0.0",
36 "cdn.jsdelivr.net:443",
37 "data.jsdelivr.com:443",
38 "jsr.io:443",
39 "deno.land:443",
40 "esm.sh:443",
41 "registry.npmjs.org:443",
42 "raw.githubusercontent.com:443",
43 "git.wyvern.rip:443",
44 "strapi.volpeon.ink:443",
45 "icy.wyvern.rip:443",
46 "public.api.bsky.app:443",
47 "127.0.0.1:1337"
48 ],
49 "env": true,
50 "run": true,
51 "ffi": true,
52 "sys": true
53 }
54 },
55 "tasks": {
56 "corvos": {
57 "description": "Run corvos command",
58 "command": "deno run -P=corvos corvos/cli.ts"
59 },
60 "build": {
61 "description": "Build the site for production",
62 "command": "deno task corvos"
63 },
64 "serve": {
65 "description": "Run and serve the site for development",
66 "command": "deno task corvos -s"
67 }
68 },
69 "compilerOptions": {
70 "types": ["@types/alpinejs"],
71 "lib": ["deno.window", "deno.unstable", "node", "dom"],
72 "jsx": "react-jsx",
73 "jsxImportSource": "corvos",
74 "strict": true,
75 "noUncheckedIndexedAccess": true
76 },
77 "unstable": ["temporal", "fmt-component"],
78 "lock": false,
79 "lint": {
80 "rules": {
81 "exclude": ["no-import-prefix", "ban-types", "jsx-key"]
82 }
83 }
84}
diff --git a/src/assets/InterVariable-Italic.woff2 b/src/assets/InterVariable-Italic.woff2
new file mode 100644
index 0000000..b3530f3
--- /dev/null
+++ b/src/assets/InterVariable-Italic.woff2
Binary files differ
diff --git a/src/assets/InterVariable.woff2 b/src/assets/InterVariable.woff2
new file mode 100644
index 0000000..5a8d3e7
--- /dev/null
+++ b/src/assets/InterVariable.woff2
Binary files differ
diff --git a/src/assets/favicon.svg b/src/assets/favicon.svg
new file mode 100644
index 0000000..ec8d3f1
--- /dev/null
+++ b/src/assets/favicon.svg
@@ -0,0 +1,15 @@
1<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
2 <path
3 d="M26.993,16L22.988,11.995L18.219,11.995L22.224,16L16.86,16L30.001,29.141L27.141,32L19.808,32L24.277,27.531L19.758,23.012L15.187,23.012L17.589,25.413L11.002,32L11.002,28.396L12.559,26.839L8.274,22.554L10.739,20.088L6.013,15.362L7.483,13.891L4.604,11.009L1.517,11.009L0,9.492L6.006,3.486L6.006,6.006L10.008,6.005L10.008,7.272L12.005,9.269L14.002,7.272L14.002,0L18.432,0L16.438,1.994L20.427,1.994L34.435,16L26.993,16ZM28,17L32,21L27.231,21L23.231,17L28,17Z" />
4 <style>
5 path {
6 fill: #000;
7 }
8
9 @media (prefers-color-scheme: dark) {
10 path {
11 fill: #FFF;
12 }
13 }
14 </style>
15</svg>
diff --git a/src/assets/symbols.svg b/src/assets/symbols.svg
new file mode 100644
index 0000000..e54a7e3
--- /dev/null
+++ b/src/assets/symbols.svg
@@ -0,0 +1,413 @@
1<svg
2 version="1.1"
3 xmlns="http://www.w3.org/2000/svg"
4 xmlns:xlink="http://www.w3.org/1999/xlink"
5 xml:space="preserve"
6 xmlns:serif="http://www.serif.com/"
7>
8 <symbol id="logo" viewBox="0 0 2700 600" preserveAspectRatio="xMinYMin">
9 <use href="#logo-sm" />
10
11 <path
12 d="M798.1,165.25L872.79,165.25L906.285,257.65L938.24,343.89L932.08,343.89L963.65,257.65L997.53,165.25L1071.45,165.25L969.04,434.75L900.51,434.75L798.1,165.25Z"
13 style="fill:currentColor;"
14 />
15 <path
16 d="M1223.14,440.14C1194.91,440.14 1169.82,434.173 1147.87,422.238C1125.93,410.303 1108.73,393.683 1096.28,372.38C1083.83,351.077 1077.61,326.95 1077.61,300C1077.61,272.793 1083.83,248.603 1096.28,227.428C1108.73,206.253 1125.99,189.698 1148.07,177.762C1170.14,165.827 1195.16,159.86 1223.14,159.86C1251.12,159.86 1276.08,165.827 1298.02,177.762C1319.97,189.698 1337.16,206.253 1349.61,227.428C1362.06,248.603 1368.29,272.793 1368.29,300C1368.29,326.95 1362.06,351.077 1349.61,372.38C1337.16,393.683 1319.97,410.303 1298.02,422.238C1276.08,434.173 1251.12,440.14 1223.14,440.14ZM1223.14,375.46C1237.51,375.46 1250.35,372.252 1261.64,365.835C1272.93,359.418 1281.72,350.499 1288.01,339.078C1294.3,327.656 1297.44,314.63 1297.44,300C1297.44,285.37 1294.3,272.344 1288.01,260.923C1281.72,249.501 1272.93,240.582 1261.64,234.165C1250.35,227.748 1237.51,224.54 1223.14,224.54C1208.77,224.54 1195.93,227.748 1184.64,234.165C1173.35,240.582 1164.49,249.501 1158.08,260.923C1151.66,272.344 1148.45,285.37 1148.45,300C1148.45,314.63 1151.66,327.656 1158.08,339.078C1164.49,350.499 1173.35,359.418 1184.64,365.835C1195.93,372.252 1208.77,375.46 1223.14,375.46Z"
17 style="fill:currentColor;"
18 />
19 <path
20 d="M1413.71,165.25L1483.02,165.25L1483.02,399.715L1453.37,370.07L1596.59,370.07L1596.59,434.75L1413.71,434.75L1413.71,165.25Z"
21 style="fill:currentColor;"
22 />
23 <path
24 d="M1637.79,165.25L1747.12,165.25C1768.94,165.25 1788.13,169.292 1804.68,177.378C1821.24,185.463 1834.01,197.012 1842.99,212.028C1851.97,227.043 1856.47,244.56 1856.47,264.58C1856.47,284.6 1851.97,302.117 1842.99,317.133C1834.01,332.148 1821.24,343.698 1804.68,351.783C1788.13,359.868 1768.94,363.91 1747.12,363.91L1668.2,363.91L1668.2,305.005L1740.97,305.005C1755.34,305.005 1766.5,301.476 1774.46,294.418C1782.42,287.359 1786.39,277.542 1786.39,264.965C1786.39,252.388 1782.35,242.571 1774.27,235.513C1766.18,228.454 1755.08,224.925 1740.97,224.925L1668.2,224.925L1706.32,185.655L1706.32,331.955L1707.09,340.81L1707.09,434.75L1637.79,434.75L1637.79,165.25Z"
25 style="fill:currentColor;"
26 />
27 <path
28 d="M1897.66,165.25L2078.22,165.25L2078.22,222.23L1927.31,222.23L1966.19,183.73L1966.19,416.27L1927.31,377L2082.46,377L2082.46,434.75L1897.66,434.75L1897.66,165.25ZM1935.39,269.97L2067.83,269.97L2067.83,326.565L1935.39,326.565L1935.39,269.97Z"
29 style="fill:currentColor;"
30 />
31 <path
32 d="M2259.18,440.14C2230.94,440.14 2205.85,434.173 2183.91,422.238C2161.96,410.303 2144.77,393.683 2132.32,372.38C2119.87,351.077 2113.64,326.95 2113.64,300C2113.64,272.793 2119.87,248.603 2132.32,227.428C2144.77,206.253 2162.03,189.698 2184.1,177.762C2206.17,165.827 2231.2,159.86 2259.18,159.86C2287.15,159.86 2312.11,165.827 2334.06,177.762C2356,189.698 2373.2,206.253 2385.65,227.428C2398.1,248.603 2404.32,272.793 2404.32,300C2404.32,326.95 2398.1,351.077 2385.65,372.38C2373.2,393.683 2356,410.303 2334.06,422.238C2312.11,434.173 2287.15,440.14 2259.18,440.14ZM2259.18,375.46C2273.55,375.46 2286.38,372.252 2297.68,365.835C2308.97,359.418 2317.76,350.499 2324.05,339.078C2330.34,327.656 2333.48,314.63 2333.48,300C2333.48,285.37 2330.34,272.344 2324.05,260.923C2317.76,249.501 2308.97,240.582 2297.68,234.165C2286.38,227.748 2273.55,224.54 2259.18,224.54C2244.8,224.54 2231.97,227.748 2220.68,234.165C2209.38,240.582 2200.53,249.501 2194.11,260.923C2187.69,272.344 2184.49,285.37 2184.49,300C2184.49,314.63 2187.69,327.656 2194.11,339.078C2200.53,350.499 2209.38,359.418 2220.68,365.835C2231.97,372.252 2244.8,375.46 2259.18,375.46Z"
33 style="fill:currentColor;"
34 />
35 <path
36 d="M2449.75,165.25L2507.12,165.25L2623,322.715L2617.99,323.485L2617.99,165.25L2686.53,165.25L2686.53,434.75L2629.16,434.75L2512.89,275.745L2518.28,274.59L2518.28,434.75L2449.75,434.75L2449.75,165.25Z"
37 style="fill:currentColor;"
38 />
39 </symbol>
40
41 <symbol id="logo-sm" viewBox="0 0 200 200" preserveAspectRatio="xMinYMin">
42 <path
43 d="M171.812,105.796L144.61,78.594L115.919,78.594L137.325,100L105.375,100L189.94,184.565L174.505,200L125.258,200L153.187,172.07L124.939,143.822L94.919,143.822L109.929,158.832L68.762,200L68.762,177.478L78.493,167.746L51.71,140.963L67.12,125.552L37.579,96.012L45.759,87.832L29.112,71.185L11.859,71.185L0,59.326L44.124,15.201L44.124,34.031L67.12,34.031L67.12,45.45L78.695,57.025L90.269,45.45L90.269,0L115.201,0L100,15.201L130.405,15.201L150.785,35.579L123.803,35.573L143.829,55.6L170.86,55.651L221.016,105.796L171.812,105.796ZM31.055,43.344C26.613,43.344 23.006,46.951 23.006,51.393C23.006,55.836 26.613,59.443 31.055,59.443C35.498,59.443 39.105,55.836 39.105,51.393C39.105,46.951 35.498,43.344 31.055,43.344ZM178.102,112.047L200,133.945L171.309,133.945L149.411,112.047L178.102,112.047Z"
44 style="fill:currentColor"
45 />
46 </symbol>
47
48 <symbol id="site-footer" viewBox="0 0 3840 1600" preserveAspectRatio="xMidYMax">
49 <rect
50 x="0"
51 y="1360"
52 width="3840"
53 height="240"
54 style="fill:var(--site-footer--bg, rgb(41,39,45));"
55 />
56
57 <g id="Moon" style="transform: translateY(var(--site-footer--moon-y, 0px));">
58 <g>
59 <circle
60 cx="1043.51"
61 cy="677.436"
62 r="141.34"
63 style="fill:var(--site-footer--star, #fff);"
64 />
65 </g>
66 </g>
67 <g id="Stars" style="transform: translateY(var(--site-footer--star-y, 0px));">
68 <g>
69 <circle
70 cx="1699.27"
71 cy="544.216"
72 r="5.328"
73 style="fill:var(--site-footer--star, #fff);"
74 />
75 <circle
76 cx="1646.14"
77 cy="856.455"
78 r="5.328"
79 style="fill:var(--site-footer--star, #fff);"
80 />
81 <circle
82 cx="2416.95"
83 cy="623.907"
84 r="5.328"
85 style="fill:var(--site-footer--star, #fff);"
86 />
87 <circle
88 cx="3379.97"
89 cy="783.855"
90 r="5.328"
91 style="fill:var(--site-footer--star, #fff);"
92 />
93 <circle
94 cx="2736.62"
95 cy="912.99"
96 r="5.328"
97 style="fill:var(--site-footer--star, #fff);"
98 />
99 <circle
100 cx="3016.91"
101 cy="533.56"
102 r="5.328"
103 style="fill:var(--site-footer--star, #fff);"
104 />
105 <circle
106 cx="1484.2"
107 cy="778.528"
108 r="2.664"
109 style="fill:var(--site-footer--star, #fff);"
110 />
111 <circle
112 cx="1100.74"
113 cy="981.29"
114 r="2.664"
115 style="fill:var(--site-footer--star, #fff);"
116 />
117 <circle
118 cx="196.68"
119 cy="942.185"
120 r="2.664"
121 style="fill:var(--site-footer--star, #fff);"
122 />
123 <circle
124 cx="774.638"
125 cy="1080.71"
126 r="2.664"
127 style="fill:var(--site-footer--star, #fff);"
128 />
129 <circle
130 cx="851.735"
131 cy="860.82"
132 r="2.664"
133 style="fill:var(--site-footer--star, #fff);"
134 />
135 <circle
136 cx="371.871"
137 cy="584.434"
138 r="2.664"
139 style="fill:var(--site-footer--star, #fff);"
140 />
141 <circle
142 cx="1720.66"
143 cy="1145.82"
144 r="2.664"
145 style="fill:var(--site-footer--star, #fff);"
146 />
147 <circle
148 cx="1764.59"
149 cy="711.93"
150 r="2.664"
151 style="fill:var(--site-footer--star, #fff);"
152 />
153 <circle
154 cx="2262.17"
155 cy="666.541"
156 r="2.664"
157 style="fill:var(--site-footer--star, #fff);"
158 />
159 <circle
160 cx="2691.87"
161 cy="647.565"
162 r="2.664"
163 style="fill:var(--site-footer--star, #fff);"
164 />
165 <circle
166 cx="3088.24"
167 cy="866.505"
168 r="2.664"
169 style="fill:var(--site-footer--star, #fff);"
170 />
171 <circle
172 cx="3657.07"
173 cy="834.16"
174 r="2.664"
175 style="fill:var(--site-footer--star, #fff);"
176 />
177 <circle
178 cx="3542.12"
179 cy="553.771"
180 r="2.664"
181 style="fill:var(--site-footer--star, #fff);"
182 />
183 <circle
184 cx="2958.94"
185 cy="963.22"
186 r="2.664"
187 style="fill:var(--site-footer--star, #fff);"
188 />
189 <circle
190 cx="2434.39"
191 cy="1041.53"
192 r="2.664"
193 style="fill:var(--site-footer--star, #fff);"
194 />
195 </g>
196 </g>
197 <g id="Cloud" style="transform: translateY(var(--site-footer--cloud-y, 0px));">
198 <g>
199 <path
200 d="M1046.44,761.258C1037.57,738.638 1015.54,722.604 989.792,722.604C982.836,722.604 976.151,723.774 969.923,725.928C952.459,692.734 917.626,670.076 877.543,670.076C865.972,670.076 854.839,671.965 844.434,675.449C809.767,624.335 751.197,590.721 684.839,590.721C616.252,590.721 555.986,626.63 521.832,680.658C499.625,658.932 469.24,645.533 435.749,645.533C370.278,645.533 316.673,696.743 312.838,761.258L1046.44,761.258Z"
201 style="fill:var(--site-footer--cloud, #36343A);"
202 />
203 </g>
204 </g>
205 <g
206 id="Cloud1"
207 serif:id="Cloud"
208 style="transform: translateY(var(--site-footer--cloud-y, 0px));"
209 >
210 <g>
211 <path
212 d="M3238.9,776.138C3237.32,733.626 3202.31,699.598 3159.41,699.598C3148.5,699.598 3138.09,701.801 3128.62,705.787C3102.93,655.293 3050.46,620.67 2989.97,620.67C2935.04,620.67 2886.73,649.214 2859.07,692.265C2845.15,684.431 2829.09,679.961 2812,679.961C2776.74,679.961 2745.89,698.974 2729.14,727.298C2720.82,723.501 2711.58,721.385 2701.84,721.385C2669.26,721.385 2642.18,745.067 2636.87,776.138L3238.9,776.138Z"
213 style="fill:var(--site-footer--cloud, #36343A);"
214 />
215 </g>
216 </g>
217 <g
218 id="Cloud2"
219 serif:id="Cloud"
220 style="transform: translateY(var(--site-footer--cloud-y, 0px));"
221 >
222 <g>
223 <path
224 d="M1781.38,740.368C1781.53,738.122 1781.6,735.858 1781.6,733.577C1781.6,675.957 1734.82,629.178 1677.2,629.178C1659.2,629.178 1642.27,633.742 1627.48,641.774C1606.62,610.476 1571,589.839 1530.61,589.839C1478.27,589.839 1433.97,624.469 1419.37,672.044C1404.68,661.872 1386.85,655.911 1367.64,655.911C1319.61,655.911 1280.22,693.19 1276.85,740.368L1781.38,740.368Z"
225 style="fill:var(--site-footer--cloud, #36343A);"
226 />
227 </g>
228 </g>
229 <g id="BG" style="transform: translateY(var(--site-footer--l1-y, 0px));">
230 <g>
231 <path
232 d="M0,1404.44L50.539,1404.44L103.371,1345.75L135.693,1345.75L181.301,1279.9L1049.31,1279.9L1111.63,1351.9L1509.23,1351.9L1553.81,1404.44L1813.22,1404.44L1864.66,1330.21L2171.38,1330.21L2224.59,1262.98L2577.01,1262.98L2674.55,1367.11L2992.16,1367.11L3027.66,1404.44L3840,1404.44L3840,2000L0,2000L0,1404.44Z"
233 style="fill:var(--site-footer--l1, rgb(24,22,29));"
234 />
235 <path
236 d="M849.371,1111.33L898.466,1310.88L800.276,1310.88L849.371,1111.33Z"
237 style="fill:var(--site-footer--l1, rgb(24,22,29));"
238 />
239 <path
240 d="M931.197,1192.12L980.292,1391.67L882.102,1391.67L931.197,1192.12Z"
241 style="fill:var(--site-footer--l1, rgb(24,22,29));"
242 />
243 <path
244 d="M1236.74,1223.18L1285.84,1422.73L1187.64,1422.73L1236.74,1223.18Z"
245 style="fill:var(--site-footer--l1, rgb(24,22,29));"
246 />
247 <path
248 d="M1365.43,1175.49L1414.53,1375.05L1316.34,1375.05L1365.43,1175.49Z"
249 style="fill:var(--site-footer--l1, rgb(24,22,29));"
250 />
251 <path
252 d="M2460.51,1102.33L2509.61,1301.89L2411.42,1301.89L2460.51,1102.33Z"
253 style="fill:var(--site-footer--l1, rgb(24,22,29));"
254 />
255 <path
256 d="M2369.71,1151.81L2418.8,1351.37L2320.61,1351.37L2369.71,1151.81Z"
257 style="fill:var(--site-footer--l1, rgb(24,22,29));"
258 />
259 <path
260 d="M3748.44,1235.46L3797.53,1435.02L3699.35,1435.02L3748.44,1235.46Z"
261 style="fill:var(--site-footer--l1, rgb(24,22,29));"
262 />
263 </g>
264 </g>
265 <g id="Dragon-Awake" serif:id="Dragon Awake">
266 <g>
267 <path
268 d="M3129.18,1304.49C3129.18,1304.49 3135.89,1291.42 3162.48,1239.64C3189.58,1186.9 3176.08,1147.44 3135.34,1147.44L3067.07,1147.44L3056.05,1131.05L3072.58,1111.46C3072.58,1092.59 3083.19,1074.18 3095.14,1067.91L3095.14,1092.76L3103.87,1092.76C3114.47,1077.86 3130.57,1067.91 3144.76,1067.91L3130.66,1098.47L3162.79,1098.47L3153.57,1111.46L3187.93,1121.1L3175.42,1131.05L3220.77,1160.43L3347.94,1067.91C3347.94,1067.91 3324.76,1097.7 3295.98,1134.5C3255.53,1186.21 3279,1230.05 3340.18,1206.24C3364.42,1196.81 3386.19,1188.58 3386.19,1188.58L3249.46,1337.7L3129.18,1337.7L3129.18,1304.49ZM3109.76,1105.31C3103.54,1105.31 3098.5,1110.36 3098.5,1116.58C3098.5,1122.79 3103.54,1127.84 3109.76,1127.84C3115.98,1127.84 3121.03,1122.79 3121.03,1116.58C3121.03,1110.36 3115.98,1105.31 3109.76,1105.31Z"
269 style="fill:var(--site-footer--dragon-awake, transparent);"
270 />
271 <path
272 d="M3117.9,1156.8C3131.98,1162.99 3136.11,1180.55 3124.53,1195.94L3137.91,1195.94C3134.63,1214.49 3139.03,1229.93 3153.92,1239.94C3147.6,1240.52 3139.46,1238.69 3134.46,1235.24L3081.9,1337.7L3036.77,1337.7L3036.77,1304.49L3068.03,1216.52L3117.9,1156.8Z"
273 style="fill:var(--site-footer--dragon-awake, transparent);"
274 />
275 <path
276 d="M3283.36,1337.7L3312.28,1306.45L3312.28,1322.65C3344.81,1287.03 3380.18,1295.56 3397.01,1337.7L3283.36,1337.7Z"
277 style="fill:var(--site-footer--dragon-awake, transparent);"
278 />
279 </g>
280 </g>
281 <g id="Dragon-Asleep" serif:id="Dragon Asleep">
282 <g>
283 <circle
284 cx="3309.17"
285 cy="1197.08"
286 r="15.426"
287 style="fill:var(--site-footer--dragon-asleep--eye, #fff);"
288 />
289 <path
290 d="M2994.68,1254.32C3066.74,1218.37 3133.74,1254.32 3178.54,1254.32C3208.13,1254.32 3219.6,1240.67 3249.2,1240.67L3281.09,1240.67C3298.06,1240.67 3315.37,1261.06 3324.43,1261.06C3333.5,1261.06 3341.03,1253.52 3341.03,1244.46C3341.03,1235.39 3333.5,1227.86 3324.43,1227.86L3266.35,1227.86L3255.32,1211.48L3271.86,1191.88C3271.86,1173.02 3282.47,1154.61 3294.41,1148.34L3294.41,1173.18L3303.14,1173.18C3313.75,1158.28 3329.85,1148.34 3344.04,1148.34L3329.93,1178.9L3362.07,1178.9L3352.84,1191.88L3387.21,1201.52L3374.7,1211.48C3374.7,1211.48 3384.74,1223.8 3398.51,1240.67C3414.68,1260.47 3420.79,1278.2 3420.79,1303.77L3466.54,1303.77C3491.29,1303.77 3504.57,1307.41 3515.99,1332.43L2848.75,1332.43C2756.41,1332.43 2733.27,1542.79 2629.7,1542.79L2473.31,1542.79C2501.14,1485.67 2551.42,1477.75 2592.95,1527.35L2605.71,1507.26C2605.71,1519.21 2614.89,1530.05 2626.85,1530.05C2706.65,1530.05 2716.87,1302.62 2846.08,1280.36C2908.65,1269.58 2955.39,1273.92 2994.68,1254.32ZM3309.04,1185.73C3302.82,1185.73 3297.77,1190.78 3297.77,1197C3297.77,1203.22 3302.82,1208.26 3309.04,1208.26C3315.26,1208.26 3320.3,1203.22 3320.3,1197C3320.3,1190.78 3315.26,1185.73 3309.04,1185.73Z"
291 style="fill:var(--site-footer--dragon-asleep, #000);"
292 />
293 </g>
294 </g>
295 <path
296 id="FG"
297 d="M1426.79,1449.74L1598.5,1449.74L1684.36,1540L1808.3,1540L1817.66,1516.69L1878.3,1521.85L1884.85,1540L2335.12,1540L2379.15,1467.78L2477.43,1496.57L2494.17,1540L2670.66,1540L2751.39,1427.41L2783.87,1427.41L2816.63,1330.21L3536.24,1330.21L3630.37,1415.61L3659.42,1477.48L3840,1477.48L3840,2000L0,2000L0,1477.48L169.867,1477.48L237.724,1380.1L740.493,1380.1L800.556,1449.74L1094.6,1449.74L1119.79,1485.2L1247.73,1485.2L1270.13,1449.74L1288.6,1390.04L1355.63,1358.1L1382.43,1408.08L1417.17,1422.49L1426.79,1449.74Z"
298 style="fill:var(--site-footer--fg, #000);"
299 />
300 <g id="Grass">
301 <g>
302 <path
303 d="M1964.26,1447.6C1964.5,1447.67 1964.76,1447.65 1964.99,1447.73C1965,1447.73 1966.48,1448.43 1968.05,1449.28C1994.05,1463.24 2010.58,1495.73 2012.41,1534.31C2012.76,1541.77 2012.39,1546.56 2012.39,1546.58C2012.21,1547.21 2012.14,1547.88 2011.84,1548.47C2011.7,1548.74 2010.91,1550.28 2008.97,1550.91C2006.97,1551.56 2005.38,1550.74 2005.11,1550.59C2003.3,1549.66 2002.76,1548.01 2002.67,1547.72C2002.19,1546.28 2002.4,1546.25 2002.49,1544.73C2002.83,1538.77 2003.83,1502.46 1984.46,1471.42C1976.87,1459.26 1967.66,1450.66 1964.26,1447.6Z"
304 style="fill:var(--site-footer--fg, #000);"
305 />
306 <path
307 d="M1940.18,1496.65C1942.03,1496.46 1956.02,1494.27 1972.12,1501.52C1996.72,1512.59 2003.83,1536 2004.87,1545.54C2005.11,1547.75 2003.92,1549.15 2003.71,1549.4C2003.29,1549.88 2002.78,1550.3 2002.21,1550.6C2000.34,1551.56 1998.64,1551 1998.35,1550.9C1996.42,1550.27 1995.62,1548.73 1995.48,1548.46C1994.76,1547.07 1995.02,1546.99 1994.78,1545.43C1993.81,1538.91 1990.55,1529.71 1984.21,1521.7C1981.44,1518.19 1976.23,1512.33 1966.76,1506.98C1965.27,1506.14 1958.34,1501.96 1940.18,1496.65Z"
308 style="fill:var(--site-footer--fg, #000);"
309 />
310 </g>
311 </g>
312 <g id="Grass1" serif:id="Grass">
313 <g>
314 <path
315 d="M914.865,1353.59C918.601,1356.1 936.504,1366.93 943.547,1399.88C944.1,1402.46 949.844,1426.61 942.861,1462.86C941.841,1468.15 940.963,1471.42 940.959,1471.43C940.664,1472.02 940.472,1472.67 940.068,1473.19C938.816,1474.79 937.094,1475.01 936.795,1475.05C934.71,1475.32 933.3,1474.22 933.056,1474.03C931.454,1472.78 931.229,1471.05 931.19,1470.76C931.006,1469.35 931.176,1469.35 931.528,1467.98C932.629,1463.67 945.08,1413.35 928.571,1375.31C923.046,1362.59 915.993,1354.89 914.865,1353.59Z"
316 style="fill:var(--site-footer--fg, #000);"
317 />
318 <path
319 d="M883.65,1402.93C885.951,1403.27 901.116,1404.64 915.997,1418.61C938.217,1439.47 939.399,1469.8 939.375,1469.94C939.267,1470.59 939.277,1471.27 939.044,1471.89C938.937,1472.17 938.322,1473.79 936.469,1474.64C934.557,1475.51 932.887,1474.87 932.597,1474.76C930.698,1474.04 929.973,1472.46 929.847,1472.19C929.211,1470.81 929.434,1470.75 929.319,1469.23C928.997,1464.99 926.888,1445.48 913.454,1427.58C908.608,1421.13 900.749,1412.52 883.65,1402.93Z"
320 style="fill:var(--site-footer--fg, #000);"
321 />
322 <path
323 d="M951.126,1470.45C951.127,1470.44 951.128,1470.43 951.128,1470.43C951.126,1470.46 951.125,1470.47 951.126,1470.45ZM951.128,1470.43C951.13,1470.41 951.13,1470.41 951.128,1470.43ZM951.126,1470.45C951.103,1470.64 950.982,1471.57 950.599,1472.34C950.464,1472.61 949.687,1474.16 947.765,1474.82C947.472,1474.92 945.782,1475.5 943.899,1474.57C942.075,1473.66 941.514,1472.02 941.416,1471.73C940.957,1470.39 941.08,1470.36 941.224,1468.94C941.598,1465.28 943.546,1450.5 951.826,1435C963.679,1412.82 982.921,1399.84 1002.9,1397.08C1004.23,1396.9 1005.61,1396.76 1005.62,1396.76C1005.87,1396.77 1006.11,1396.89 1006.36,1396.9C986.272,1405.86 973.972,1416.5 965.016,1430.57C952.605,1450.08 951.153,1469.9 951.126,1470.45Z"
324 style="fill:var(--site-footer--fg, #000);"
325 />
326 </g>
327 </g>
328 <g id="Grass2" serif:id="Grass">
329 <g>
330 <path
331 d="M458.456,1404C458.477,1404.38 458.483,1405.06 458.326,1405.68C458.251,1405.98 457.825,1407.66 456.085,1408.71C455.82,1408.87 454.289,1409.8 452.25,1409.29C451.957,1409.21 450.274,1408.79 449.221,1407.05C448.556,1405.95 448.639,1405.9 448.467,1404.63C446.814,1392.39 446.731,1379.27 446.717,1377.06C446.608,1359.92 448.297,1342.01 452.941,1325.57C460.174,1299.96 471.1,1289.59 474.677,1286C467.832,1297.99 462.126,1313.35 459.008,1333.34C453.666,1367.57 457.88,1400.23 458.456,1404ZM458.456,1404C458.447,1403.85 458.437,1403.74 458.433,1403.7C458.547,1404.48 458.54,1404.55 458.456,1404ZM458.433,1403.7C458.43,1403.69 458.431,1403.69 458.433,1403.7Z"
332 style="fill:var(--site-footer--fg, #000);"
333 />
334 <path
335 d="M392.718,1331.94C395.17,1332.02 412.732,1331.6 428.759,1344.91C455.13,1366.81 451.323,1404.53 451.175,1405.03C450.986,1405.65 450.883,1406.33 450.562,1406.9C449.569,1408.68 447.901,1409.16 447.612,1409.24C445.592,1409.83 444.033,1408.95 443.763,1408.8C441.991,1407.81 441.505,1406.14 441.42,1405.85C441.018,1404.47 441.209,1404.45 441.32,1403.02C441.634,1398.95 442.726,1377.03 430.267,1358.46C421.782,1345.81 409.582,1338.25 392.718,1331.94Z"
336 style="fill:var(--site-footer--fg, #000);"
337 />
338 <path
339 d="M462.967,1404.49C462.969,1404.46 462.97,1404.44 462.97,1404.42C462.97,1404.53 462.968,1404.55 462.967,1404.49ZM462.967,1404.49C462.953,1404.75 462.887,1405.62 462.577,1406.37C462.461,1406.65 461.795,1408.25 459.923,1409.04C459.638,1409.16 457.991,1409.86 456.048,1409.06C455.769,1408.94 454.165,1408.28 453.373,1406.4C452.876,1405.23 452.956,1405.2 452.977,1403.92C453.12,1395.18 454.889,1386.49 455.193,1385C462.837,1347.44 486.107,1331.12 510.591,1335.1C511.659,1335.27 511.652,1335.28 512.824,1335.49C495.33,1338.05 478.5,1345.1 469.039,1370.99C463.05,1387.37 462.952,1403.61 462.967,1404.49ZM462.97,1404.42C462.97,1404.4 462.971,1404.41 462.97,1404.42Z"
340 style="fill:var(--site-footer--fg, #000);"
341 />
342 </g>
343 </g>
344 <g id="Tree">
345 <g>
346 <path
347 d="M2070.78,639.766C2070.78,710.098 2135.65,740.217 2188.85,759.283C2206.09,765.464 2159.42,776.932 2140.44,771.373C2168.96,792.343 2207.29,804.943 2243.82,818.39C2266.38,826.697 2188.43,832.896 2153.66,818.39C2153.66,818.39 2163.7,837.928 2170.2,850.792C2179.2,868.606 2095.6,825.355 2070.78,806.784L2025.44,806.784C1999.8,825.821 1915.56,869.191 1925.33,851.276C1932.06,838.946 1943.15,818.39 1943.15,818.39C1909.87,837.85 1840.29,826.677 1860.34,818.39C1893.38,804.74 1929.15,791.382 1957.84,769.618C1940.08,772.877 1892.24,764.541 1907.77,759.283C1968.01,738.879 2025.44,710.502 2025.44,639.766L2025.44,616.15L2070.78,616.15L2070.78,639.766Z"
348 style="fill:var(--site-footer--fg, #000);"
349 />
350 <rect
351 x="2018.84"
352 y="1165.1"
353 width="59.623"
354 height="692.272"
355 style="fill:var(--site-footer--fg, #000);"
356 />
357 <path
358 d="M2076.84,1043.84C2076.84,1114.17 2135.51,1144.29 2188.71,1163.36C2205.95,1169.54 2146.96,1181.01 2127.98,1175.45C2156.5,1196.42 2207.15,1218.3 2243.68,1231.75C2263.02,1238.87 2210.31,1269.17 2130.83,1255.59C2130.83,1255.59 2133.04,1265.37 2136.13,1279.52C2138.33,1289.6 2086.1,1271.11 2070.64,1244.83L2025.3,1244.83C2010.05,1271.11 1957.4,1289.99 1961.34,1279.76C1966.27,1266.98 1969.86,1258.18 1969.86,1258.18C1895.25,1274.47 1843.08,1238.82 1860.2,1231.75C1893.24,1218.1 1941.51,1195.46 1970.2,1173.69C1952.44,1176.95 1892.1,1168.62 1907.63,1163.36C1967.88,1142.95 2019.1,1114.58 2019.1,1043.84L2019.1,1020.23L2076.84,1020.23L2076.84,1043.84Z"
359 style="fill:var(--site-footer--fg, #000);"
360 />
361 <path
362 d="M2071.08,805.061C2071.08,875.393 2136.13,914.507 2199.21,924.578C2218.34,927.632 2167.4,953.246 2146.89,947.687C2177.72,968.658 2253.82,995.302 2311.02,1006.41C2332.2,1010.52 2259.3,1039.94 2179.01,1032.81C2179.01,1032.81 2221.8,1056.94 2257.49,1069.58C2293.18,1082.22 2166.02,1109.84 2071.08,1057.76L2025.74,1057.76C1942.32,1109.32 1805.06,1082.46 1837.24,1071.75C1883.04,1056.51 1915.35,1034 1915.35,1034C1844.15,1036.32 1772.71,1011.23 1793.58,1006.41C1846.44,994.199 1918.52,967.697 1949.53,945.932C1930.33,949.191 1878.24,927.289 1895.4,924.578C1959.08,914.518 2025.74,875.796 2025.74,805.061L2025.74,781.445L2071.08,781.445L2071.08,805.061Z"
363 style="fill:var(--site-footer--fg, #000);"
364 />
365 <path
366 d="M2051.79,446.256C2051.79,446.256 2097.14,551.378 2179.34,614.851C2194.93,626.89 2136.67,633.349 2110.62,626.401C2110.62,626.401 2127.97,653.465 2136.47,666.47C2147.5,683.333 2093.49,658.353 2071.08,629.181L2025.74,629.181C2003.5,658.781 1949.29,682.946 1961.19,665.734C1970.94,651.633 1988.47,626.401 1988.47,626.401C1962.52,633.418 1909.82,626.446 1923.59,615.533C2005.91,550.291 2051.79,446.256 2051.79,446.256Z"
367 style="fill:var(--site-footer--fg, #000);"
368 />
369 </g>
370 </g>
371 <g id="Tree1" serif:id="Tree">
372 <g>
373 <path
374 d="M565.291,486.652C565.291,486.652 608.588,590.252 689.204,634.627C704.448,640.091 655.441,650.227 640.039,645.314C663.182,663.848 690.806,674.985 719.625,686.871C737.428,694.213 675.927,699.692 648.49,686.871C648.49,686.871 656.409,704.139 661.538,715.51C668.64,731.255 603.657,693.027 583.511,676.612L546.72,676.612C525.916,693.438 460.627,731.772 468.339,715.937C473.646,705.039 482.397,686.871 482.397,686.871C456.138,704.07 401.237,694.195 417.062,686.871C443.13,674.805 468.586,662.999 491.865,643.762C477.454,646.643 428.009,641.497 440.765,634.627C520.33,591.782 565.291,486.652 565.291,486.652Z"
375 style="fill:var(--site-footer--fg, #000);"
376 />
377 <rect
378 x="535.054"
379 y="1042.06"
380 width="59.623"
381 height="692.272"
382 style="fill:var(--site-footer--fg, #000);"
383 />
384 <path
385 d="M593.05,920.803C593.05,991.135 651.724,1021.25 704.92,1040.32C722.167,1046.5 663.177,1057.97 644.196,1052.41C672.717,1073.38 723.367,1095.26 759.894,1108.71C779.235,1115.83 726.523,1146.13 647.047,1132.55C647.047,1132.55 649.257,1142.33 652.346,1156.48C654.546,1166.56 602.318,1148.07 586.852,1121.79L541.514,1121.79C526.26,1148.07 473.611,1166.95 477.556,1156.72C482.486,1143.94 486.076,1135.14 486.076,1135.14C411.467,1151.43 359.293,1115.78 376.417,1108.71C409.457,1095.06 457.724,1072.42 486.411,1050.65C468.652,1053.91 408.314,1045.58 423.84,1040.32C484.089,1019.92 535.317,991.538 535.317,920.803L535.317,897.187L593.05,897.187L593.05,920.803Z"
386 style="fill:var(--site-footer--fg, #000);"
387 />
388 <path
389 d="M587.292,682.02C587.292,752.353 644.509,792.408 701.696,801.537C719.04,804.306 672.861,827.527 654.263,822.487C682.209,841.498 751.199,865.652 803.056,875.722C822.258,879.451 756.167,906.114 683.383,899.654C683.383,899.654 722.173,921.528 754.53,932.986C786.887,944.445 671.61,969.481 585.539,922.273L544.438,922.273C468.813,969.016 344.383,944.666 373.557,934.956C415.076,921.138 444.366,900.735 444.366,900.735C379.817,902.833 315.054,880.093 333.974,875.722C381.892,864.652 447.238,840.626 475.347,820.896C457.946,823.85 410.72,803.995 426.28,801.537C484.009,792.418 541.954,752.756 541.954,682.02L541.954,658.404L587.292,658.404L587.292,682.02Z"
390 style="fill:var(--site-footer--fg, #000);"
391 />
392 </g>
393 </g>
394 </symbol>
395
396 <symbol id="site-footer-bg" viewBox="0 0 100 1600" preserveAspectRatio="none">
397 <rect
398 x="0"
399 y="1360"
400 width="100"
401 height="240"
402 style="fill:var(--site-footer--bg, rgb(41,39,45));"
403 />
404 <rect
405 x="0"
406 y="1404.44"
407 width="100"
408 height="595.555"
409 style="fill:var(--site-footer--l1, rgb(24,22,29)); transform: translateY(var(--site-footer--l1-y, 0px));"
410 />
411 <rect id="FG" x="-0" y="1477.48" width="100" height="522.519" />
412 </symbol>
413</svg>
diff --git a/src/data/nav.ts b/src/data/nav.ts
new file mode 100644
index 0000000..2f9efb9
--- /dev/null
+++ b/src/data/nav.ts
@@ -0,0 +1,9 @@
1export const nav: {
2 label: string;
3 url: string;
4 icon: "house" | "package";
5 hidden?: boolean;
6}[] = [
7 { icon: "house", label: "Home", url: "/" },
8 { icon: "package", label: "Releases", url: "/releases/" },
9];
diff --git a/src/deps/strapi.tsx b/src/deps/strapi.tsx
new file mode 100644
index 0000000..de2e495
--- /dev/null
+++ b/src/deps/strapi.tsx
@@ -0,0 +1,416 @@
1import type { Pipeline } from "corvos";
2import type { StandardSchemaV1 } from "corvos/deps/standard_schema.ts";
3
4import { type } from "arktype";
5import { Page, PipelineStageError } from "corvos";
6import { Severity } from "corvos/core/error.ts";
7import { replaceExtension } from "corvos/core/utils/path.ts";
8
9import { ContentImage } from "$/ui/objects/ContentImage.tsx";
10
11export enum HeadingBlockLevel {
12 L1 = 1,
13 L2 = 2,
14 L3 = 3,
15 L4 = 4,
16 L5 = 5,
17 L6 = 6,
18}
19
20export const fileAttributeSchema = type({
21 "alternativeText?": "string | null",
22 "caption?": "string | null",
23 "createdAt": "string",
24 "documentId": "string",
25 "ext": "string",
26 "formats?": "Record<string, unknown> | null",
27 "hash": "string",
28 "height?": "number | null",
29 "mime": "string",
30 "name": "string",
31 "previewUrl?": "string | null",
32 "provider": "string",
33 "provider_metadata?": "unknown",
34 "size": "number",
35 "updatedAt": "string",
36 "url": "string",
37 "width?": "number | null",
38});
39
40export type FileAttribute = typeof fileAttributeSchema.infer;
41
42export const imageAttributeSchema = fileAttributeSchema.narrow(
43 (o): o is Omit<typeof o, "width" | "height"> & { width: number; height: number } =>
44 !!o.width && !!o.height,
45);
46
47export type ImageAttribute = typeof imageAttributeSchema.infer;
48
49const blocksAttributeScope = type
50 .scope({
51 codeBlockNode: {
52 "children": "defaultInlineNode[]",
53 "language?": "string",
54 "type": "'code'",
55 },
56
57 defaultInlineNode: "textInlineNode | linkInlineNode",
58
59 headingBlockNode: {
60 children: "defaultInlineNode[]",
61 level: "1 | 2 | 3 | 4 | 5 | 6",
62 type: "'heading'",
63 },
64
65 imageBlockNode: {
66 children: type({
67 text: "string",
68 type: "'text'",
69 }).array(),
70 image: fileAttributeSchema.omit("documentId"),
71 type: "'image'",
72 },
73
74 linkInlineNode: {
75 children: "textInlineNode[]",
76 type: "'link'",
77 url: "string",
78 },
79
80 listBlockNode: {
81 children: "(listItemInlineNode | listBlockNode)[]",
82 format: "'ordered' | 'unordered'",
83 type: "'list'",
84 },
85
86 listItemInlineNode: {
87 children: "defaultInlineNode[]",
88 type: "'list-item'",
89 },
90
91 node: "rootNode | nonTextInlineNode",
92
93 nonTextInlineNode: "linkInlineNode | listItemInlineNode",
94
95 paragraphBlockNode: {
96 children: "defaultInlineNode[]",
97 type: "'paragraph'",
98 },
99
100 quoteBlockNode: {
101 children: "defaultInlineNode[]",
102 type: "'quote'",
103 },
104
105 rootNode:
106 "paragraphBlockNode | quoteBlockNode | codeBlockNode | headingBlockNode | listBlockNode | imageBlockNode",
107
108 textInlineNode: {
109 "bold?": "boolean",
110 "code?": "boolean",
111 "italic?": "boolean",
112 "strikethrough?": "boolean",
113 "text": "string",
114 "type": "'text'",
115 "underline?": "boolean",
116 },
117 })
118 .export();
119
120export const defaultInlineNodeSchema = blocksAttributeScope.defaultInlineNode;
121
122export type DefaultInlineNode = typeof defaultInlineNodeSchema.infer;
123
124export const nodeSchema = blocksAttributeScope.node;
125
126export type Node = typeof nodeSchema.infer;
127
128export const strapiItemSchema = type({
129 "[string]": "unknown",
130 "documentId": "string",
131 "publishedAt": "string.date.parse | null",
132 "updatedAt": "string.date.parse",
133});
134
135export type StrapiItem = typeof strapiItemSchema.infer;
136
137export function walkBlocks(
138 content: (Node | DefaultInlineNode)[],
139 fn: (_: Node | DefaultInlineNode) => unknown,
140): void {
141 for (const node of content) {
142 fn(node);
143
144 switch (node.type) {
145 case "heading":
146 case "code":
147 case "quote":
148 case "paragraph":
149 case "list":
150 case "list-item":
151 case "link": {
152 return walkBlocks(node.children, fn);
153 }
154 }
155 }
156}
157
158function blocksNodeToTSX(node: Node | DefaultInlineNode) {
159 switch (true) {
160 case node.type === "heading": {
161 switch (node.level) {
162 case 1:
163 return <h1>{blocksToTSX(node.children)}</h1>;
164 case 2:
165 return <h2>{blocksToTSX(node.children)}</h2>;
166 case 3:
167 return <h3>{blocksToTSX(node.children)}</h3>;
168 case 4:
169 return <h4>{blocksToTSX(node.children)}</h4>;
170 case 5:
171 return <h5>{blocksToTSX(node.children)}</h5>;
172 default:
173 return <h6>{blocksToTSX(node.children)}</h6>;
174 }
175 }
176
177 case node.type === "image" && !!node.image.caption: {
178 return (
179 <figure>
180 <ContentImage src={node.image.url} alt={node.image.alternativeText ?? undefined} />
181 <figcaption>{node.image.caption}</figcaption>
182 </figure>
183 );
184 }
185
186 case node.type === "image": {
187 return <ContentImage src={node.image.url} alt={node.image.alternativeText ?? undefined} />;
188 }
189
190 case node.type === "code": {
191 return (
192 <pre>
193 <code>{blocksToTSX(node.children)}</code>
194 </pre>
195 );
196 }
197
198 case node.type === "quote": {
199 return <blockquote>{blocksToTSX(node.children)}</blockquote>;
200 }
201
202 case node.type === "paragraph" &&
203 node.children.length === 1 &&
204 node.children[0]?.type === "text" &&
205 node.children[0].text === "---": {
206 return <hr />;
207 }
208
209 case node.type === "paragraph": {
210 return <p>{blocksToTSX(node.children)}</p>;
211 }
212
213 case node.type === "list" && node.format === "ordered": {
214 return <ol>${blocksToTSX(node.children)}</ol>;
215 }
216
217 case node.type === "list": {
218 return <ul>${blocksToTSX(node.children)}</ul>;
219 }
220
221 case node.type === "list-item": {
222 return <li>${blocksToTSX(node.children)}</li>;
223 }
224
225 case node.type === "link": {
226 return <a href={node.url}>{blocksToTSX(node.children)}</a>;
227 }
228
229 case node.type === "text": {
230 return [
231 node.bold ? "strong" : undefined,
232 node.code ? "code" : undefined,
233 node.italic ? "em" : undefined,
234 node.strikethrough ? "del" : undefined,
235 node.underline ? "u" : undefined,
236 ].reduce((res: JSX.Element, Tag) => (Tag ? <Tag>{res}</Tag> : res), node.text);
237 }
238 }
239}
240
241export function blocksToTSX(
242 content: (Node | DefaultInlineNode)[],
243 filter: (node: Node | DefaultInlineNode) => boolean = () => true,
244): JSX.Element {
245 return content
246 .filter(filter)
247 .map(blocksNodeToTSX)
248 .filter((s) => s !== undefined);
249}
250
251export const strapiCollectionResponseSchema = type({
252 data: strapiItemSchema.array(),
253 meta: {
254 pagination: {
255 page: "number",
256 pageCount: "number",
257 pageSize: "number",
258 total: "number",
259 },
260 },
261});
262
263export const strapiItemResponseSchema = type({
264 data: strapiItemSchema,
265});
266
267async function* loadPaginated(url: URL | string, init?: RequestInit) {
268 let pageSize: number | undefined;
269 let pages = 1;
270
271 for (let i = 0; i < pages; ++i) {
272 const pageUrl = new URL(url);
273
274 if (pageSize) {
275 pageUrl.searchParams.set("pagination[page]", `${i + 1}`);
276 pageUrl.searchParams.set("pagination[pageSize]", `${pageSize}`);
277 }
278
279 const res = await fetch(pageUrl, init);
280 const json = await res.json();
281
282 const dec = strapiCollectionResponseSchema(json);
283
284 if (dec instanceof type.errors) {
285 throw new Error(dec.summary);
286 }
287
288 pages = dec.meta.pagination.pageCount;
289 pageSize = dec.meta.pagination.pageSize;
290
291 for (const item of dec.data) {
292 yield item;
293 }
294 }
295}
296
297export class StrapiLoader {
298 init: RequestInit;
299
300 constructor(
301 readonly host: string | URL,
302 token: string,
303 readonly publicHost = host,
304 ) {
305 this.init = {
306 headers: {
307 Authorization: `Bearer ${token}`,
308 },
309 keepalive: true,
310 };
311 }
312
313 async loadSingle<T>(
314 collection: string,
315 schema: StandardSchemaV1<unknown, T>,
316 query: Record<string, string> = {},
317 ) {
318 const url = new URL(`/api/${collection}`, this.host);
319 Object.entries(query).forEach(([key, value]) => {
320 url.searchParams.append(key, value);
321 });
322
323 const res = await fetch(url, this.init);
324 const json = await res.json();
325
326 const dec = strapiItemResponseSchema(json);
327
328 if (dec instanceof type.errors) {
329 throw new Error(`Schema mismatch in ${url}:\n${dec.summary}`);
330 }
331
332 const dec2 = await schema["~standard"].validate(dec.data);
333
334 if (dec2.issues) {
335 throw new Error(`Schema mismatch in ${url}:\n${dec2.issues.join("\n")}`);
336 }
337
338 return {
339 documentId: dec.data.documentId,
340 publishedAt: dec.data.publishedAt,
341 updatedAt: dec.data.updatedAt,
342 ...dec2.value,
343 };
344 }
345
346 async *loadCollection<T>(
347 collection: string,
348 schema: StandardSchemaV1<unknown, T>,
349 query: Record<string, string> = {},
350 ) {
351 const url = new URL(`/api/${collection}`, this.host);
352 Object.entries(query).forEach(([key, value]) => {
353 url.searchParams.append(key, value);
354 });
355
356 for await (const item of loadPaginated(url, this.init)) {
357 const dec = await schema["~standard"].validate(item);
358
359 if (dec.issues) {
360 yield new PipelineStageError(
361 Severity.ERROR,
362 `Schema mismatch in ${url}`,
363 dec.issues.join("\n"),
364 );
365 } else {
366 yield {
367 documentId: item.documentId,
368 publishedAt: item.publishedAt,
369 updatedAt: item.updatedAt,
370 ...dec.value,
371 };
372 }
373 }
374 }
375
376 readonly resolvedFileAttributeSchema = fileAttributeSchema.pipe(({ url, previewUrl, ...o }) => ({
377 ...o,
378 url: new URL(url, this.publicHost).href,
379 ...(previewUrl ? { previewUrl: new URL(previewUrl, this.publicHost).href } : {}),
380 }));
381
382 readonly resolvedImageAttributeSchema = imageAttributeSchema.pipe((o) => ({
383 ...o,
384 previewUrl: o.previewUrl ? new URL(o.previewUrl, this.publicHost).href : undefined,
385 url: new URL(o.url, this.publicHost).href,
386 }));
387}
388
389export function strapi() {
390 return <Loaders extends string, Out extends { ".strapi": [Node[], {}] }, Helpers extends {}>(
391 pipeline: Pipeline<Loaders, Out, Helpers>,
392 ) =>
393 pipeline.process("strapi", ".strapi", function* (page) {
394 yield new Page(
395 ".jsxelement",
396 blocksToTSX(page.content),
397 page.data as Out[".strapi"][1],
398 replaceExtension(page.url, ".jsx"),
399 page,
400 );
401 });
402}
403
404export const Blocks = ({
405 content,
406 filter = () => true,
407}: {
408 content: (Node | DefaultInlineNode)[];
409 filter?: (node: Node | DefaultInlineNode) => boolean;
410}) => blocksToTSX(content, filter);
411
412const strapiUrl = Deno.env.get("STRAPI_URL") ?? "";
413const strapiPublicUrl = Deno.env.get("STRAPI_PUBLIC_URL");
414const strapiToken = Deno.env.get("STRAPI_TOKEN") ?? "";
415
416export const strapiLoader = new StrapiLoader(strapiUrl, strapiToken, strapiPublicUrl);
diff --git a/src/pages/index.mdx b/src/pages/index.mdx
new file mode 100644
index 0000000..1d35a1d
--- /dev/null
+++ b/src/pages/index.mdx
@@ -0,0 +1,10 @@
1---
2type: "home"
3title: "Corvos · static site generator"
4description: "Project website of Corvos, a static site generator for Deno."
5---
6
7import { template } from "./index.tsx";
8export { template };
9
10Corvos is a Static Site Generator with the goal to provide a customizeable, type-safe experience.
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
new file mode 100644
index 0000000..b1581b7
--- /dev/null
+++ b/src/pages/index.tsx
@@ -0,0 +1,64 @@
1import { BaseLayout } from "$/ui/base/BaseLayout.tsx";
2import { ReleaseCard } from "$/ui/components/ReleaseCard.tsx";
3import { SiteHeader } from "$/ui/components/SiteHeader.tsx";
4import { CardList } from "$/ui/layouts/CardList.tsx";
5import { Container } from "$/ui/layouts/Container.tsx";
6import { Button } from "$/ui/objects/Button.tsx";
7import { Divider } from "$/ui/objects/Divider.tsx";
8import { Heading } from "$/ui/objects/Heading.tsx";
9import { Icon } from "$/ui/objects/Icon.tsx";
10import site from "@/corvos.config.ts";
11
12export const template = site.$.template({ type: "home" }, (page, pages) => {
13 const query = site.$.query(pages);
14 const releasesIndex = query.type(".html").match({ type: "releases-index" }).one();
15 const releases = query.type(".html").match({ type: "release" }).many().toArray();
16
17 return (
18 <BaseLayout page={page}>
19 <SiteHeader
20 url={page.url}
21 sm={
22 <div class="l-media__block l-media__block--main">
23 <Heading class="u-mbs-0 u-elp">{page.data.title}</Heading>
24 </div>
25 }
26 />
27
28 <Container fixed="150" pad-inline pad-block class="c-home-banner">
29 <Heading as="h1" class="c-home-banner__intro">
30 <strong class="o-heading__highlight">
31 <a class="p-name u-url u-uid" href={site.$.url("", true)}>
32 Corvos
33 </a>
34 </strong>{" "}
35 &ndash; A static site generator for Deno.
36 </Heading>
37
38 <div class="u-mbs-400 c-subtitle">{{ __html: page.content }}</div>
39
40 <div class="u-mbs-700">
41 <Button
42 href={releasesIndex.url}
43 post={<Icon icon="arrow-right" variant="bold" />}
44 size="xl"
45 variant="primary"
46 wide
47 >
48 Releases
49 </Button>
50 </div>
51 </Container>
52
53 <Divider level="faint" />
54
55 <Container class="u-mbs-neutralize" fixed="150" pad-block pad-inline>
56 <CardList keyboard-nav>
57 {releases.map((page) => (
58 <ReleaseCard page={page} heading-level="h3" list />
59 ))}
60 </CardList>
61 </Container>
62 </BaseLayout>
63 );
64});
diff --git a/src/pages/pkg/corvos@[version].ts b/src/pages/pkg/corvos@[version].ts
new file mode 100644
index 0000000..6dcb65a
--- /dev/null
+++ b/src/pages/pkg/corvos@[version].ts
@@ -0,0 +1,35 @@
1import * as zip from "@zip-js/zip-js";
2import { Page, PipelineStageError } from "corvos";
3import { binaryLoader } from "corvos/core/loaders/binary.ts";
4
5import { strapiLoader } from "$/deps/strapi.tsx";
6import { releaseStrapiSchema } from "$/schemas/releases.ts";
7import site from "@/corvos.config.ts";
8
9export const pages = site.$.pageGenerator(async function* () {
10 for await (const item of strapiLoader.loadCollection("corvos-releases", releaseStrapiSchema, {
11 "populate[0]": "files",
12 "sort[0]": "date:desc",
13 })) {
14 if (item instanceof PipelineStageError) {
15 yield item;
16 continue;
17 }
18
19 const res = await binaryLoader(new URL(item.files.url));
20 const reader = new zip.ZipReader(new zip.Uint8ArrayReader(res.content));
21
22 for await (const entry of reader.getEntriesGenerator()) {
23 if (entry.directory) {
24 continue;
25 }
26
27 yield new Page(
28 ".",
29 new Uint8Array(await entry.arrayBuffer()),
30 { version: item.version },
31 `/pkg/corvos@${item.version}/${entry.filename}`,
32 );
33 }
34 }
35});
diff --git a/src/pages/releases/[version].tsx b/src/pages/releases/[version].tsx
new file mode 100644
index 0000000..84d807a
--- /dev/null
+++ b/src/pages/releases/[version].tsx
@@ -0,0 +1,100 @@
1import { Page, PipelineStageError } from "corvos";
2
3import { strapiLoader } from "$/deps/strapi.tsx";
4import { releaseStrapiSchema } from "$/schemas/releases.ts";
5import { BaseLayout } from "$/ui/base/BaseLayout.tsx";
6import { ReleaseCard } from "$/ui/components/ReleaseCard.tsx";
7import { SiteHeader } from "$/ui/components/SiteHeader.tsx";
8import { CardList } from "$/ui/layouts/CardList.tsx";
9import { Container } from "$/ui/layouts/Container.tsx";
10import { HList } from "$/ui/layouts/HList.tsx";
11import { ActionButton } from "$/ui/objects/ActionButton.tsx";
12import { Badge } from "$/ui/objects/Badge.tsx";
13import { Body } from "$/ui/objects/Body.tsx";
14import { Divider } from "$/ui/objects/Divider.tsx";
15import { Heading } from "$/ui/objects/Heading.tsx";
16import { Icon } from "$/ui/objects/Icon.tsx";
17import site from "@/corvos.config.ts";
18
19export const pages = site.$.pageGenerator(async function* () {
20 for await (const item of strapiLoader.loadCollection("corvos-releases", releaseStrapiSchema, {
21 "populate[0]": "files",
22 "sort[0]": "date:desc",
23 })) {
24 if (item instanceof PipelineStageError) {
25 yield item;
26 } else {
27 yield new Page(".strapi", item.notes ?? [], {
28 ...item,
29 title: `Corvos ${item.version}`,
30 type: "release" as const,
31 });
32 }
33 }
34});
35
36export const template = site.$.template({ type: "release" }, (page, pages) => {
37 const query = site.$.query(pages);
38
39 const feed = query.type(".rss").match({ type: "releases" }).one();
40
41 const prev = query.type(".html").match({ type: "release" }).prev(page);
42 const next = query.type(".html").match({ type: "release" }).next(page);
43
44 return (
45 <BaseLayout page={page}>
46 <SiteHeader
47 mode="compact"
48 url={page.url}
49 end={
50 <div class="l-media__block">
51 <ActionButton
52 align-block
53 class="u-mbs-0"
54 href={feed.url}
55 icon={<Icon icon="rss-simple" variant="bold" />}
56 level="quiet"
57 pill
58 title="RSS Feed"
59 />
60 </div>
61 }
62 >
63 <div class="l-media__block l-media__block--main">
64 <Heading class="u-mbs-0 u-elp">{page.data.title}</Heading>
65 </div>
66 </SiteHeader>
67
68 <Container as="main" class="u-mbs-neutralize h-entry" fixed="150" pad-block pad-inline>
69 <HList aria-label="Categories">
70 <li>
71 <Badge as="strong" pill selected>
72 <time class="dt-published" datetime={page.data.date.toISOString()}>
73 {page.data.date.toLocaleDateString(undefined, {
74 day: "numeric",
75 month: "short",
76 year: "numeric",
77 })}
78 </time>
79 </Badge>
80 </li>
81 </HList>
82
83 <Heading as="h1" class="u-mbs-200 p-name" display highlight level="xxl">
84 {page.data.title}
85 </Heading>
86
87 <Body class="e-content">{{ __html: page.content }}</Body>
88 </Container>
89
90 <Divider level="faint" />
91
92 <Container as="footer" class="u-mbs-neutralize" fixed="150" pad-block pad-inline>
93 <CardList layout="grid">
94 {prev && <ReleaseCard heading-level="h2" page={prev} list />}
95 {next && <ReleaseCard heading-level="h2" page={next} list />}
96 </CardList>
97 </Container>
98 </BaseLayout>
99 );
100});
diff --git a/src/pages/releases/feed.ts b/src/pages/releases/feed.ts
new file mode 100644
index 0000000..b430ca5
--- /dev/null
+++ b/src/pages/releases/feed.ts
@@ -0,0 +1,19 @@
1import type { FeedItem } from "corvos/plugins/feeds.ts";
2
3import site from "@/corvos.config.ts";
4
5export const feeds = site.$.feedGenerator(function* (pages) {
6 const items = site.$.query(pages)
7 .match({ type: "release" })
8 .many()
9 .take(20)
10 .map(
11 (page): FeedItem => ({
12 title: page.data.title,
13 url: page.url,
14 content: page.content,
15 updated: page.data.date,
16 }),
17 );
18 yield { type: "releases", title: `Releases`, url: "/releases/", items };
19});
diff --git a/src/pages/releases/index.mdx b/src/pages/releases/index.mdx
new file mode 100644
index 0000000..6519c76
--- /dev/null
+++ b/src/pages/releases/index.mdx
@@ -0,0 +1,8 @@
1---
2type: "releases-index"
3title: "Releases"
4description: "All releases of Corvos."
5---
6
7import { template } from "./index.tsx";
8export { template };
diff --git a/src/pages/releases/index.tsx b/src/pages/releases/index.tsx
new file mode 100644
index 0000000..132eee7
--- /dev/null
+++ b/src/pages/releases/index.tsx
@@ -0,0 +1,51 @@
1import { BaseLayout } from "$/ui/base/BaseLayout.tsx";
2import { ReleaseCard } from "$/ui/components/ReleaseCard.tsx";
3import { SiteHeader } from "$/ui/components/SiteHeader.tsx";
4import { CardList } from "$/ui/layouts/CardList.tsx";
5import { Container } from "$/ui/layouts/Container.tsx";
6import { ActionButton } from "$/ui/objects/ActionButton.tsx";
7import { Heading } from "$/ui/objects/Heading.tsx";
8import { Icon } from "$/ui/objects/Icon.tsx";
9import { PlaceholderBox } from "$/ui/objects/PlaceholderBox.tsx";
10import site from "@/corvos.config.ts";
11
12export const template = site.$.template({ type: "releases-index" }, (page, pages) => {
13 const query = site.$.query(pages);
14 const items = query.type(".html").match({ type: "release" }).many().toArray();
15 const feed = query.type(".rss").match({ type: "releases" }).one();
16
17 return (
18 <BaseLayout page={page}>
19 <SiteHeader
20 url={page.url}
21 end={
22 <div class="l-media__block">
23 <ActionButton
24 align-block
25 class="u-mbs-0"
26 href={feed.url}
27 icon={<Icon icon="rss-simple" variant="bold" />}
28 level="quiet"
29 pill
30 title="RSS Feed"
31 />
32 </div>
33 }
34 sm={
35 <div class="l-media__block l-media__block--main">
36 <Heading class="u-mbs-0 u-elp">{page.data.title}</Heading>
37 </div>
38 }
39 ></SiteHeader>
40
41 <Container as="main" fixed pad-block pad-inline>
42 <CardList keyboard-nav>
43 {items.map((item) => (
44 <ReleaseCard page={item} heading-level="h2" list />
45 ))}
46 {!items.length && <PlaceholderBox as="em">Nothing there yet</PlaceholderBox>}
47 </CardList>
48 </Container>
49 </BaseLayout>
50 );
51});
diff --git a/src/pages/sitemap.ts b/src/pages/sitemap.ts
new file mode 100644
index 0000000..378a9ed
--- /dev/null
+++ b/src/pages/sitemap.ts
@@ -0,0 +1,8 @@
1import site from "@/corvos.config.ts";
2
3export const sitemap = site.$.sitemap((pages) => ({
4 items: pages.map((page) => ({
5 loc: page.url,
6 lastmod: page.data.modifiedAt,
7 })),
8}));
diff --git a/src/schemas/misc.ts b/src/schemas/misc.ts
new file mode 100644
index 0000000..e86df72
--- /dev/null
+++ b/src/schemas/misc.ts
@@ -0,0 +1,16 @@
1import { type } from "arktype";
2
3export const miscSchema = type.or(
4 {
5 type: "'home'",
6 title: "string",
7 description: "string",
8 },
9 {
10 type: "'releases-index'",
11 title: "string",
12 description: "string",
13 },
14);
15
16export type Misc = typeof miscSchema.infer;
diff --git a/src/schemas/mod.ts b/src/schemas/mod.ts
new file mode 100644
index 0000000..69d6c6f
--- /dev/null
+++ b/src/schemas/mod.ts
@@ -0,0 +1,8 @@
1import { type } from "arktype";
2
3import { miscSchema } from "$/schemas/misc.ts";
4import { releaseSchema } from "$/schemas/releases.ts";
5
6export const pageDataSchema = type.or(releaseSchema, miscSchema);
7
8export type PageData = typeof pageDataSchema.infer;
diff --git a/src/schemas/releases.ts b/src/schemas/releases.ts
new file mode 100644
index 0000000..0ccb6a6
--- /dev/null
+++ b/src/schemas/releases.ts
@@ -0,0 +1,18 @@
1import { type } from "arktype";
2
3import { nodeSchema } from "../deps/strapi.tsx";
4import { strapiLoader } from "../deps/strapi.tsx";
5
6export const releaseStrapiSchema = type({
7 "version": "string",
8 "date": "string.date.parse",
9 "notes?": nodeSchema.array().or("null"),
10 "files": strapiLoader.resolvedFileAttributeSchema,
11});
12
13export const releaseSchema = releaseStrapiSchema.merge({
14 type: "'release'",
15 title: "string",
16});
17
18export type Release = typeof releaseSchema.infer;
diff --git a/src/scripts/lib/dom.ts b/src/scripts/lib/dom.ts
new file mode 100644
index 0000000..a88686a
--- /dev/null
+++ b/src/scripts/lib/dom.ts
@@ -0,0 +1,59 @@
1function isAbove(a: HTMLElement, b: HTMLElement) {
2 return a.offsetTop + a.clientHeight < b.offsetTop;
3}
4
5export function getVerticalNeighbor(
6 dir: -1 | 1,
7 items: Iterable<HTMLElement>,
8 current: HTMLElement,
9) {
10 let candidates: HTMLElement[] = [];
11
12 if (dir === -1) {
13 let adjacentY = Number.MIN_SAFE_INTEGER;
14
15 for (const item of items) {
16 if (!isAbove(item, current)) {
17 continue;
18 }
19
20 if (item.offsetTop > adjacentY) {
21 adjacentY = item.offsetTop;
22 candidates = [item];
23 } else if (item.offsetTop === adjacentY) {
24 candidates.push(item);
25 }
26 }
27 } else {
28 let adjacentY = Number.MAX_SAFE_INTEGER;
29
30 for (const item of items) {
31 if (!isAbove(current, item)) {
32 continue;
33 }
34
35 if (item.offsetTop < adjacentY) {
36 adjacentY = item.offsetTop;
37 candidates = [item];
38 } else if (item.offsetTop === adjacentY) {
39 candidates.push(item);
40 }
41 }
42 }
43
44 const currentX = current.offsetLeft + 0.5 * current.clientWidth;
45 let match = candidates[0];
46 let bestDist = Number.MAX_SAFE_INTEGER;
47
48 for (const candidate of candidates) {
49 const candidateX = candidate.offsetLeft + 0.5 * candidate.clientWidth;
50 const dist = Math.abs(currentX - candidateX);
51
52 if (dist < bestDist) {
53 bestDist = dist;
54 match = candidate;
55 }
56 }
57
58 return match;
59}
diff --git a/src/scripts/lib/keyboardNav.ts b/src/scripts/lib/keyboardNav.ts
new file mode 100644
index 0000000..4c79a08
--- /dev/null
+++ b/src/scripts/lib/keyboardNav.ts
@@ -0,0 +1,272 @@
1import { getVerticalNeighbor } from "./dom.ts";
2
3function focusPrev(
4 owner: HTMLElement,
5 itemSelector: string,
6 wrap: boolean,
7 stateful: boolean,
8 current: HTMLElement,
9) {
10 const items = [...owner.querySelectorAll<HTMLElement>(itemSelector)];
11 const i = items.indexOf(current);
12
13 const prev = i > 0 ? items[i - 1] : wrap ? items.at(-1) : undefined;
14
15 if (!prev || prev === current) {
16 return false;
17 }
18
19 if (stateful) {
20 current.setAttribute("tabindex", "-1");
21 prev.removeAttribute("tabindex");
22 }
23 prev.focus();
24 return true;
25}
26
27function focusNext(
28 owner: HTMLElement,
29 itemSelector: string,
30 wrap: boolean,
31 stateful: boolean,
32 current: HTMLElement,
33) {
34 const items = [...owner.querySelectorAll<HTMLElement>(itemSelector)];
35 const i = items.indexOf(current);
36
37 const next = i < items.length - 1 ? items[i + 1] : wrap ? items[0] : undefined;
38
39 if (!next || next === current) {
40 return false;
41 }
42
43 if (stateful) {
44 current.setAttribute("tabindex", "-1");
45 next.removeAttribute("tabindex");
46 }
47 next.focus();
48 return true;
49}
50
51function focusUp(
52 owner: HTMLElement,
53 itemSelector: string,
54 wrap: boolean,
55 stateful: boolean,
56 current: HTMLElement,
57) {
58 const items = [...owner.querySelectorAll<HTMLElement>(itemSelector)];
59 let neighbor: HTMLElement | undefined = getVerticalNeighbor(-1, items, current);
60
61 if (!neighbor && wrap) {
62 neighbor = items.at(-1);
63 }
64
65 if (!neighbor || neighbor === current) {
66 return false;
67 }
68
69 if (stateful) {
70 current.setAttribute("tabindex", "-1");
71 neighbor.removeAttribute("tabindex");
72 }
73 neighbor.focus();
74 return true;
75}
76
77function focusDown(
78 owner: HTMLElement,
79 itemSelector: string,
80 wrap: boolean,
81 stateful: boolean,
82 current: HTMLElement,
83) {
84 const items = owner.querySelectorAll<HTMLElement>(itemSelector);
85 let neighbor = getVerticalNeighbor(1, items, current);
86
87 if (!neighbor && wrap) {
88 neighbor = items[0];
89 }
90
91 if (!neighbor || neighbor === current) {
92 return false;
93 }
94
95 if (stateful) {
96 current.setAttribute("tabindex", "-1");
97 neighbor.removeAttribute("tabindex");
98 }
99 neighbor.focus();
100 return true;
101}
102
103function focusFirst(
104 owner: HTMLElement,
105 itemSelector: string,
106 stateful: boolean,
107 current: HTMLElement,
108) {
109 const cards = [...owner.querySelectorAll<HTMLElement>(itemSelector)];
110 const first = cards[0];
111
112 if (!first || first === current) {
113 return false;
114 }
115
116 if (stateful) {
117 current.setAttribute("tabindex", "-1");
118 first.removeAttribute("tabindex");
119 }
120 first.focus();
121 return true;
122}
123
124function focusLast(
125 owner: HTMLElement,
126 itemSelector: string,
127 stateful: boolean,
128 current: HTMLElement,
129) {
130 const cards = [...owner.querySelectorAll<HTMLElement>(itemSelector)];
131 const last = cards.at(-1);
132
133 if (!last || last === current) {
134 return false;
135 }
136
137 if (stateful) {
138 current.setAttribute("tabindex", "-1");
139 last.removeAttribute("tabindex");
140 }
141 last.focus();
142 return true;
143}
144
145function onKeyDown(
146 owner: HTMLElement,
147 itemSelector: string,
148 horizontal: boolean,
149 vertical: boolean,
150 wrap: boolean,
151 stateful: boolean,
152 e: KeyboardEvent,
153) {
154 let handled = false;
155
156 if (!e.currentTarget) {
157 return;
158 }
159
160 switch (e.key) {
161 case "ArrowLeft": {
162 if (horizontal) {
163 handled = focusPrev(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement);
164 }
165 break;
166 }
167
168 case "ArrowRight": {
169 if (horizontal) {
170 handled = focusNext(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement);
171 }
172 break;
173 }
174
175 case "ArrowUp": {
176 if (vertical) {
177 handled = focusUp(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement);
178 }
179 break;
180 }
181
182 case "ArrowDown": {
183 if (vertical) {
184 handled = focusDown(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement);
185 }
186 break;
187 }
188
189 case "Home": {
190 handled = focusFirst(owner, itemSelector, stateful, e.currentTarget as HTMLElement);
191 break;
192 }
193
194 case "End": {
195 handled = focusLast(owner, itemSelector, stateful, e.currentTarget as HTMLElement);
196 break;
197 }
198 }
199
200 if (handled) {
201 e.stopPropagation();
202 e.preventDefault();
203 }
204}
205
206export function mountKeyboardNav() {
207 const keyboardNavEls = document.querySelectorAll<HTMLElement>(".js-keyboard-nav");
208
209 for (const keyboardNavEl of keyboardNavEls) {
210 const itemSelector = keyboardNavEl.dataset.keyboardNavItem ?? "";
211
212 if (!itemSelector) {
213 console.error("Keyboard nav: No item class provided");
214 continue;
215 }
216
217 let horizontal = false,
218 vertical = false,
219 wrap = false,
220 stateful = false;
221
222 switch (keyboardNavEl.dataset.keyboardNavMode) {
223 case "grid": {
224 horizontal = true;
225 vertical = true;
226 stateful = true;
227 break;
228 }
229
230 case "nav": {
231 horizontal = true;
232 wrap = true;
233 break;
234 }
235
236 case "menu": {
237 vertical = true;
238 wrap = true;
239 break;
240 }
241
242 case "list": {
243 vertical = true;
244 wrap = true;
245 stateful = true;
246 break;
247 }
248 }
249
250 const onKeyDown_ = (e: KeyboardEvent) => {
251 onKeyDown(keyboardNavEl, itemSelector, horizontal, vertical, wrap, stateful, e);
252 };
253
254 const itemEls = [...keyboardNavEl.querySelectorAll<HTMLElement>(itemSelector)].filter((el) =>
255 el.matches("a, button, input"),
256 );
257 let focused = false;
258
259 for (const itemEl of itemEls) {
260 itemEl.addEventListener("keydown", onKeyDown_);
261 if (!itemEl.classList.contains("is-selected")) {
262 itemEl.setAttribute("tabindex", "-1");
263 } else {
264 focused = true;
265 }
266 }
267
268 if (!focused) {
269 itemEls[0]?.removeAttribute("tabindex");
270 }
271 }
272}
diff --git a/src/scripts/lib/lightbox.ts b/src/scripts/lib/lightbox.ts
new file mode 100644
index 0000000..ac5b581
--- /dev/null
+++ b/src/scripts/lib/lightbox.ts
@@ -0,0 +1,450 @@
1import Alpine from "alpinejs";
2
3export interface LightboxItem {
4 thumbnail?: string;
5 src: string;
6 srcset?: string;
7 alt: string;
8}
9
10function touchParams(e: TouchEvent) {
11 const [t0, t1] = e.touches;
12
13 if (e.touches.length > 2 || !t0) {
14 return;
15 }
16
17 let x = t0.clientX;
18 let y = t0.clientY;
19 let dist = 0;
20
21 if (t1) {
22 dist = Math.hypot(x - t1.clientX, y - t1.clientY);
23 x = (x + t1.clientX) / 2;
24 y = (y + t1.clientY) / 2;
25 }
26
27 return { x, y, dist };
28}
29
30export function mountLightbox() {
31 const minZoom = 0.9;
32
33 Alpine.data("lightbox", () => ({
34 _zoom: minZoom,
35 _offsetX: 0,
36 _offsetY: 0,
37
38 loading: false,
39 inertia: false,
40 items: <LightboxItem[]>[],
41 current: 0,
42 dragging: false,
43 touchStart: {
44 x: 0,
45 y: 0,
46 distance: 0,
47 transformOrigin: { x: 0, y: 0 },
48 zoom: 1,
49 offsetX: 0,
50 offsetY: 0,
51 timestamp: 0,
52 },
53 velocity: {
54 measure: false,
55 prevOffsetX: 0,
56 prevOffsetY: 0,
57 timestamp: 0,
58 x: 0,
59 y: 0,
60 },
61
62 get zoom() {
63 return this._zoom;
64 },
65 set zoom(value) {
66 this._zoom = Math.min(this.maxZoom, Math.max(minZoom, value));
67 /* oxlint-disable-next-line eslint/no-self-assign */ // deno-lint-ignore no-self-assign
68 this.offsetX = this.offsetX;
69 /* oxlint-disable-next-line eslint/no-self-assign */ // deno-lint-ignore no-self-assign
70 this.offsetY = this.offsetY;
71 },
72
73 get offsetX() {
74 return this._offsetX;
75 },
76 set offsetX(value) {
77 const limit = this.offsetLimit;
78 this._offsetX = Math.min(limit.x, Math.max(-limit.x, value));
79 },
80
81 get offsetY() {
82 return this._offsetY;
83 },
84 set offsetY(value) {
85 const limit = this.offsetLimit;
86 this._offsetY = Math.min(limit.y, Math.max(-limit.y, value));
87 },
88
89 get offsetLimit(): { x: number; y: number } {
90 if (!this.$refs.img) {
91 return { x: 0, y: 0 };
92 }
93
94 const th = this.$refs.thumbnails?.clientHeight ?? 0;
95 const cw = this.$root.clientWidth;
96 const ch = this.$root.clientHeight - th;
97 const x = 0.5 * Math.max(0, this.$refs.img.clientWidth * this.zoom - cw);
98 const y = 0.5 * Math.max(0, this.$refs.img.clientHeight * this.zoom - ch);
99
100 return { x, y };
101 },
102
103 get open(): boolean {
104 return !!this.$root.hasAttribute("open");
105 },
106
107 get currentItem(): LightboxItem {
108 return this.items[this.current] ?? { src: "", alt: "" };
109 },
110
111 get transformOrigin(): { x: number; y: number } {
112 const img = this.$refs.img;
113
114 if (!img) {
115 return { x: this.offsetX, y: this.offsetY };
116 }
117
118 return {
119 x: img.offsetLeft + img.clientWidth / 2 + this.offsetX,
120 y: img.offsetTop + img.clientHeight / 2 + this.offsetY,
121 };
122 },
123
124 get naturalZoom() {
125 const img = <HTMLImageElement>this.$refs.img;
126 return img ? img.naturalWidth / img.clientWidth : 1;
127 },
128
129 get maxZoom() {
130 return Math.max(this.naturalZoom, 5);
131 },
132
133 get zoomedFull() {
134 return Math.abs(this.zoom - this.naturalZoom) < 0.1;
135 },
136
137 init() {
138 const triggerEls = document.querySelectorAll<HTMLAnchorElement>("a[data-lightbox]");
139 const galleries = new Map<string, LightboxItem[]>();
140
141 for (const triggerEl of triggerEls) {
142 const triggerImgEl = triggerEl.querySelector("img");
143
144 const key = triggerEl.dataset.lightbox ?? "";
145 const gallery = galleries.get(key);
146 const item: LightboxItem = {
147 thumbnail: triggerImgEl?.src,
148 src: triggerEl.href,
149 srcset: triggerEl.dataset.lightboxSrcset,
150 alt: "",
151 };
152
153 if (gallery) {
154 gallery.push(item);
155 } else {
156 galleries.set(key, [item]);
157 }
158
159 triggerEl.style.cursor = "zoom-in";
160 triggerEl.addEventListener("click", (e) => {
161 e.preventDefault();
162 this.show(galleries.get(key) ?? [], item.src);
163 });
164 }
165
166 this.$watch("currentItem.src", () => {
167 this.loading = true;
168 this.reset();
169 });
170 },
171
172 show(items: LightboxItem[], src?: string) {
173 const current = items.findIndex((item) => item.src === src);
174 this.items = items;
175 this.current = current === -1 ? 0 : current;
176 this.reset();
177
178 (<HTMLDialogElement>this.$root).showModal();
179 },
180
181 close() {
182 (<HTMLDialogElement>this.$root).close();
183 },
184
185 reset() {
186 this.stopVelocity();
187 this.stopInertia();
188 this.dragging = false;
189 this._zoom = minZoom;
190 this._offsetX = 0;
191 this._offsetY = 0;
192 },
193
194 prev() {
195 this.current = this.current > 0 ? this.current - 1 : this.items.length - 1;
196 },
197
198 next() {
199 this.current = this.current < this.items.length - 1 ? this.current + 1 : 0;
200 },
201
202 toggleZoom(x?: number, y?: number) {
203 const zoom = this.zoomedFull ? minZoom : this.naturalZoom;
204 const deltaZoom = zoom / this.zoom - 1;
205
206 this.zoom = zoom;
207
208 if (x !== undefined && y !== undefined) {
209 const transformOrigin = this.transformOrigin;
210 const offsetX = transformOrigin.x - x;
211 const offsetY = transformOrigin.y - y;
212
213 this.offsetX += offsetX * deltaZoom;
214 this.offsetY += offsetY * deltaZoom;
215 }
216 },
217
218 measureVelocity(loop?: boolean) {
219 if (!loop) {
220 this.inertia = false;
221
222 if (this.velocity.measure) {
223 return;
224 } else {
225 this.velocity.prevOffsetX = this.offsetX;
226 this.velocity.prevOffsetY = this.offsetY;
227 this.velocity.x = 0;
228 this.velocity.y = 0;
229 this.velocity.measure = true;
230 }
231 }
232 if (!this.velocity.measure) {
233 return;
234 }
235
236 const deltaX = this.offsetX - this.velocity.prevOffsetX;
237 const deltaY = this.offsetY - this.velocity.prevOffsetY;
238 let window = 1;
239
240 if (
241 Math.sqrt(deltaX ** 2 + deltaY ** 2) <
242 Math.sqrt(this.velocity.x ** 2 + this.velocity.y ** 2)
243 ) {
244 window = 6;
245 }
246
247 this.velocity.x -= this.velocity.x / window;
248 this.velocity.x += deltaX / window;
249 this.velocity.y -= this.velocity.y / window;
250 this.velocity.y += deltaY / window;
251
252 this.velocity.prevOffsetX = this.offsetX;
253 this.velocity.prevOffsetY = this.offsetY;
254
255 requestAnimationFrame(this.measureVelocity.bind(this, true));
256 },
257
258 stopVelocity() {
259 this.velocity.measure = false;
260 },
261
262 runInertia(loop?: boolean, timestamp: DOMHighResTimeStamp = performance.now()) {
263 const len = Math.sqrt(this.velocity.x ** 2 + this.velocity.y ** 2);
264
265 if (!loop) {
266 this.stopVelocity();
267
268 if (this.inertia) {
269 return;
270 } else if (len > 5) {
271 this.inertia = true;
272 this.velocity.timestamp = timestamp;
273 }
274 }
275
276 if (!this.inertia) {
277 this.stopInertia();
278 return;
279 }
280
281 if (loop) {
282 const norm = { x: this.velocity.x / len, y: this.velocity.y / len };
283 const elapsed = timestamp - this.velocity.timestamp;
284 const friction = 30 * (elapsed / 1000);
285 const decel = { x: norm.x * friction, y: norm.y * friction };
286 const decelLen = Math.sqrt(decel.x ** 2 + decel.y ** 2);
287
288 if (len < decelLen) {
289 this.stopInertia();
290 return;
291 }
292
293 this.offsetX += this.velocity.x;
294 this.offsetY += this.velocity.y;
295 this.velocity.x -= decel.x;
296 this.velocity.y -= decel.y;
297 this.velocity.timestamp = timestamp;
298 }
299
300 requestAnimationFrame(this.runInertia.bind(this, true));
301 },
302
303 stopInertia() {
304 this.inertia = false;
305 },
306
307 handleClose() {
308 this.stopVelocity();
309 this.stopInertia();
310 },
311
312 handleScroll(e: WheelEvent) {
313 this.stopInertia();
314
315 const direction = e.deltaY > 0 ? -1 : 1;
316
317 const transformOrigin = this.transformOrigin;
318 const offsetX = transformOrigin.x - e.clientX;
319 const offsetY = transformOrigin.y - e.clientY;
320
321 const zoom = Math.min(this.maxZoom, Math.max(minZoom, this.zoom * (1 + direction * 0.1)));
322 const deltaZoom = zoom / this.zoom - 1;
323
324 this.zoom = zoom;
325 this.offsetX += offsetX * deltaZoom;
326 this.offsetY += offsetY * deltaZoom;
327 },
328
329 handleMouseDown(e: MouseEvent) {
330 this.measureVelocity();
331
332 if (e.buttons === 1) {
333 this.dragging = true;
334 }
335 },
336
337 handleMouseMove(e: MouseEvent) {
338 if (!this.dragging) {
339 return;
340 }
341
342 this.offsetX += e.movementX;
343 this.offsetY += e.movementY;
344 },
345
346 handleMouseUp() {
347 this.dragging = false;
348 this.runInertia();
349 },
350
351 handleDoubleClick(e: MouseEvent) {
352 this.toggleZoom(e.clientX, e.clientY);
353 },
354
355 handleTouchStart(e: TouchEvent) {
356 const params = touchParams(e);
357
358 if (!params || (e.touches.length === 1 && e.target !== this.$refs.img)) {
359 return;
360 }
361
362 e.preventDefault();
363
364 const now = Date.now();
365
366 if (e.touches.length === 1) {
367 if (
368 now - this.touchStart.timestamp < 200 &&
369 Math.sqrt((params.x - this.touchStart.x) ** 2 + (params.y - this.touchStart.y) ** 2) < 20
370 ) {
371 this.toggleZoom(params.x, params.y);
372 }
373 }
374
375 this.touchStart.x = params.x;
376 this.touchStart.y = params.y;
377 this.touchStart.distance = params.dist;
378 this.touchStart.transformOrigin = this.transformOrigin;
379 this.touchStart.zoom = this.zoom;
380 this.touchStart.offsetX = this.offsetX;
381 this.touchStart.offsetY = this.offsetY;
382 this.touchStart.timestamp = now;
383
384 this.dragging = false;
385
386 if (e.touches.length === 1) {
387 this.measureVelocity();
388 } else {
389 this.stopVelocity();
390 }
391 },
392
393 handleTouchMove(e: TouchEvent) {
394 const params = touchParams(e);
395
396 if (!params || (e.touches.length === 1 && e.target !== this.$refs.img)) {
397 return;
398 }
399
400 e.preventDefault();
401
402 const deltaX = params.x - this.touchStart.x;
403 const deltaY = params.y - this.touchStart.y;
404 const deltaDist = this.touchStart.distance ? params.dist / this.touchStart.distance : 1;
405
406 const offsetX = this.touchStart.transformOrigin.x - this.touchStart.x;
407 const offsetY = this.touchStart.transformOrigin.y - this.touchStart.y;
408
409 const zoom = Math.min(this.maxZoom, Math.max(minZoom, this.touchStart.zoom * deltaDist));
410 const deltaZoom = zoom / this.touchStart.zoom - 1;
411
412 this.zoom = zoom;
413 this.offsetX = this.touchStart.offsetX + deltaX + offsetX * deltaZoom;
414 this.offsetY = this.touchStart.offsetY + deltaY + offsetY * deltaZoom;
415 },
416
417 handleTouchEnd(e: TouchEvent) {
418 this.handleTouchStart(e);
419
420 if (!e.touches.length) {
421 this.runInertia();
422 }
423 },
424
425 handleKeyDown(e: KeyboardEvent) {
426 if (!this.open || e.defaultPrevented) {
427 return;
428 }
429
430 this.stopInertia();
431
432 switch (e.key) {
433 case "Left":
434 case "ArrowLeft":
435 this.prev();
436 break;
437
438 case "Right":
439 case "ArrowRight":
440 this.next();
441 break;
442
443 default:
444 return;
445 }
446
447 e.preventDefault();
448 },
449 }));
450}
diff --git a/src/scripts/lib/siteHeader.ts b/src/scripts/lib/siteHeader.ts
new file mode 100644
index 0000000..0d9ec43
--- /dev/null
+++ b/src/scripts/lib/siteHeader.ts
@@ -0,0 +1,37 @@
1export function mountSiteHeader() {
2 const headerEl = document.querySelector<HTMLElement>(".c-site-header");
3
4 if (headerEl) {
5 const navbarEl = headerEl?.querySelector(".o-navbar--static-white");
6 const logoEl = headerEl?.querySelector(".c-logo--static-white");
7 const buttonEls = headerEl?.querySelectorAll(".o-button--static-white");
8
9 let settings: IntersectionObserverInit;
10
11 const boundingRect = headerEl.getBoundingClientRect();
12
13 if (boundingRect.top > 0) {
14 settings = {
15 rootMargin: "1px 0px -100% 0px",
16 };
17 } else {
18 settings = {
19 root: document.documentElement,
20 rootMargin: "-1px 0px 0px 0px",
21 threshold: [1],
22 };
23 }
24
25 const observer = new IntersectionObserver(([e]) => {
26 const scroll = e?.isIntersecting;
27 headerEl.classList.toggle("is-scroll", scroll);
28 navbarEl?.classList.toggle("o-navbar--static-white", !scroll);
29 logoEl?.classList.toggle("c-logo--static-white", !scroll);
30 for (const buttonEl of buttonEls) {
31 buttonEl.classList.toggle("o-button--static-white", !scroll);
32 }
33 }, settings);
34
35 observer.observe(headerEl);
36 }
37}
diff --git a/src/scripts/lib/tabbar.ts b/src/scripts/lib/tabbar.ts
new file mode 100644
index 0000000..6a3d3d3
--- /dev/null
+++ b/src/scripts/lib/tabbar.ts
@@ -0,0 +1,12 @@
1export function mountTabbar() {
2 const tabbarEls = document.querySelectorAll<HTMLElement>(".o-tabbar__tabs");
3
4 for (const tabbarEl of tabbarEls) {
5 const selectedItemEl = tabbarEl.querySelector<HTMLElement>(".o-tabbar__tab.is-selected");
6
7 if (selectedItemEl) {
8 tabbarEl.scrollLeft =
9 selectedItemEl.offsetLeft - 0.5 * tabbarEl.clientWidth + 0.5 * selectedItemEl.clientWidth;
10 }
11 }
12}
diff --git a/src/scripts/lib/toc.ts b/src/scripts/lib/toc.ts
new file mode 100644
index 0000000..958f209
--- /dev/null
+++ b/src/scripts/lib/toc.ts
@@ -0,0 +1,14 @@
1export function mountToc() {
2 const observer = new IntersectionObserver((entries) => {
3 for (const entry of entries) {
4 const id = entry.target.getAttribute("id");
5 document
6 .querySelector(`.o-toc__item[href="#${id}"]`)
7 ?.classList.toggle("is-selected", entry.intersectionRatio > 0);
8 }
9 });
10
11 document.querySelectorAll("section[id]").forEach((section) => {
12 observer.observe(section);
13 });
14}
diff --git a/src/scripts/script.ts b/src/scripts/script.ts
new file mode 100644
index 0000000..02fb7ff
--- /dev/null
+++ b/src/scripts/script.ts
@@ -0,0 +1,17 @@
1/// <reference lib="dom" />
2/// <reference lib="dom.iterable" />
3
4import { mountKeyboardNav } from "./lib/keyboardNav.ts";
5import { mountLightbox } from "./lib/lightbox.ts";
6import { mountSiteHeader } from "./lib/siteHeader.ts";
7import { mountTabbar } from "./lib/tabbar.ts";
8import { mountToc } from "./lib/toc.ts";
9
10document.body.classList.remove("t-no-js");
11document.body.classList.add("t-js");
12
13mountKeyboardNav();
14mountLightbox();
15mountTabbar();
16mountSiteHeader();
17mountToc();
diff --git a/src/styles/_common.scss b/src/styles/_common.scss
new file mode 100644
index 0000000..2b1f6c2
--- /dev/null
+++ b/src/styles/_common.scss
@@ -0,0 +1,87 @@
1@use "sass:map";
2@use "sass:math";
3@use "pkg:iro-sass/props";
4@use "pkg:iro-sass/functions" as fn;
5@use "pkg:iro-sass/easing" as easing;
6@use "pkg:iro-design/config.defaults" as defaults;
7
8$font--standard--family: props.def(
9 --font--standard--family,
10 (
11 "Inter",
12 "Open Sans",
13 "Segoe UI",
14 "Droid Sans",
15 Roboto,
16 Oxygen,
17 "Helvetica Neue",
18 Helvetica,
19 Tahoma,
20 Arial,
21 sans-serif
22 )
23);
24
25@use "pkg:iro-design/core.vars" as core;
26
27@use "./components/site-header.vars" as site-header;
28
29@use "pkg:iro-design/layouts/split-view.vars" with (
30 $panel--sticky-offset: props.def(
31 --l-split-view--panel--sticky-offset,
32 calc(props.get(site-header.$block-size) + props.get(core.$size--400))
33 )
34);
35
36@forward "pkg:iro-design";
37
38@forward "objects/deflist" as o-deflist--*;
39@forward "objects/message" as o-message--*;
40@forward "objects/placeholder-box" as o-placeholder-box--*;
41@forward "objects/toc" as o-toc--*;
42
43@forward "components/home-banner" as c-home-banner--*;
44@forward "components/logo" as c-logo--*;
45@forward "components/site-header" as c-site-header--*;
46@forward "components/site-nav" as c-site-nav--*;
47@forward "components/site-footer" as c-site-footer--*;
48@forward "components/subtitle" as c-subtitle--*;
49@forward "components/visited-indicator" as c-visited-indicator--*;
50
51@use "pkg:iro-design" as iro;
52
53@mixin styles {
54 @font-face {
55 font-family: "Inter";
56 font-style: normal;
57 font-weight: 50 950;
58 font-display: swap;
59 src: url(load("/assets/InterVariable.woff2")) format("woff2-variations");
60 font-feature-settings: "ss01";
61 }
62
63 @font-face {
64 font-family: "Inter";
65 font-style: italic;
66 font-weight: 50 950;
67 font-display: swap;
68 src: url(load("/assets/InterVariable-Italic.woff2")) format("woff2-variations");
69 font-feature-settings: "ss01";
70 }
71
72 html {
73 scroll-behavior: smooth;
74 scroll-padding-block-start: props.get(site-header.$block-size);
75 scrollbar-gutter: stable;
76 min-block-size: 100%;
77 display: flex;
78 }
79
80 body {
81 inline-size: 100%;
82 }
83
84 [x-cloak] {
85 display: none;
86 }
87}
diff --git a/src/styles/components/_home-banner.scss b/src/styles/components/_home-banner.scss
new file mode 100644
index 0000000..660cd9b
--- /dev/null
+++ b/src/styles/components/_home-banner.scss
@@ -0,0 +1,39 @@
1@use "sass:map";
2@use "sass:string";
3@use "sass:meta";
4
5@use "pkg:iro-sass/bem";
6@use "pkg:iro-sass/props";
7@use "pkg:iro-design" as iro;
8
9@use "site-header.vars" as site-header;
10
11@mixin styles {
12 @include bem.component("home-banner") {
13 @include bem.elem("intro") {
14 margin-block-start: 0;
15 font-size: clamp(props.get(iro.$core--font-size--500), 5.3vw, 5rem);
16 font-weight: 300;
17 color: props.get(iro.$core--theme, --text);
18 line-height: 1.2;
19
20 strong {
21 font-family: props.get(iro.$core--font--headline--family);
22 font-weight: 900;
23 text-transform: uppercase;
24 letter-spacing: 2px;
25 color: props.get(iro.$core--theme, --heading);
26 }
27 }
28
29 @include iro.media--media("<=md") {
30 @include bem.elem("intro") {
31 font-size: props.get(iro.$core--font-size--500);
32 }
33
34 @include bem.elem("buttons") {
35 justify-content: center;
36 }
37 }
38 }
39}
diff --git a/src/styles/components/_logo.scss b/src/styles/components/_logo.scss
new file mode 100644
index 0000000..3450817
--- /dev/null
+++ b/src/styles/components/_logo.scss
@@ -0,0 +1,81 @@
1@use "sass:map";
2@use "sass:string";
3
4@use "pkg:iro-sass/bem";
5@use "pkg:iro-sass/props";
6@use "pkg:iro-design" as iro;
7
8@mixin styles {
9 @include bem.component("logo") {
10 display: block;
11 padding: props.get(iro.$core--size--50);
12 margin-inline: calc(-1 * props.get(iro.$core--size--50));
13 border-radius: props.get(iro.$s-links--rounding);
14 color: props.get(iro.$core--theme, --heading);
15
16 &:link,
17 &:visited {
18 &:hover {
19 text-decoration: none;
20 color: props.get(iro.$core--theme, --text-mute);
21 }
22
23 &:focus-visible {
24 color: props.get(iro.$s-links--key-focus--text-color);
25 text-decoration: none;
26 outline: props.get(iro.$s-links--key-focus--border-color) solid
27 props.get(iro.$s-links--key-focus--border-width);
28 box-shadow: 0 0 0
29 calc(
30 props.get(iro.$s-links--key-focus--border-width) +
31 props.get(iro.$s-links--key-focus--outline-width)
32 )
33 props.get(iro.$s-links--key-focus--outline-color);
34 }
35 }
36
37 @include bem.elem("img") {
38 display: block;
39 block-size: 1.7em;
40 aspect-ratio: 2700 / 600;
41 }
42
43 @include bem.modifier("sm") {
44 @include bem.elem("img") {
45 aspect-ratio: 600 / 600;
46 }
47 }
48
49 @include bem.modifier("lg") {
50 @include bem.elem("img") {
51 block-size: 2em;
52 }
53 }
54
55 @each $theme in map.keys(props.get(iro.$core--transparent-colors)) {
56 $logo-theme: static-#{string.slice($theme, 3)};
57
58 @include bem.modifier($logo-theme) {
59 color: props.get(iro.$core--transparent-colors, $theme, --900);
60
61 &:link,
62 &:visited {
63 &:hover {
64 color: props.get(iro.$core--transparent-colors, $theme, --800);
65 }
66
67 &:focus-visible {
68 color: props.get(iro.$core--transparent-colors, $theme, --900);
69 outline-color: props.get(iro.$core--transparent-colors, $theme, --900);
70 box-shadow: 0 0 0
71 calc(
72 props.get(iro.$s-links--key-focus--border-width) +
73 props.get(iro.$s-links--key-focus--outline-width)
74 )
75 props.get(iro.$core--transparent-colors, $theme, --300);
76 }
77 }
78 }
79 }
80 }
81}
diff --git a/src/styles/components/_site-footer.scss b/src/styles/components/_site-footer.scss
new file mode 100644
index 0000000..33608ff
--- /dev/null
+++ b/src/styles/components/_site-footer.scss
@@ -0,0 +1,157 @@
1@use "sass:math";
2@use "sass:meta";
3
4@use "pkg:iro-design" as iro;
5@use "pkg:iro-sass/bem";
6@use "pkg:iro-sass/props";
7
8@forward "site-footer.vars";
9@use "site-footer.vars" as vars;
10
11@mixin styles {
12 @include iro.props--materialize-at-root(meta.module-variables("vars"));
13
14 @property --site-footer--l1-y {
15 initial-value: 0px;
16 inherits: true;
17 syntax: "<length>";
18 }
19
20 @property --site-footer--cloud-y {
21 initial-value: 0px;
22 inherits: true;
23 syntax: "<length>";
24 }
25
26 @property --site-footer--moon-y {
27 initial-value: 0px;
28 inherits: true;
29 syntax: "<length>";
30 }
31
32 @property --site-footer--star-y {
33 initial-value: 0px;
34 inherits: true;
35 syntax: "<length>";
36 }
37
38 @keyframes footer-l1-ani {
39 from {
40 --site-footer--l1-y: -300px;
41 --site-footer--cloud-y: -400px;
42 --site-footer--moon-y: -500px;
43 --site-footer--star-y: -500px;
44 }
45 to {
46 --site-footer--l1-y: 300px;
47 --site-footer--cloud-y: 400px;
48 --site-footer--moon-y: 500px;
49 --site-footer--star-y: 500px;
50 }
51 }
52
53 @include bem.component("site-footer") {
54 position: sticky;
55 inset-block-start: 100%;
56
57 @include bem.elem("banner") {
58 position: relative;
59 display: flex;
60 block-size: 33rem;
61 justify-content: end;
62 overflow: hidden;
63 background: linear-gradient(
64 to bottom,
65 props.get(vars.$colors, --bg-from) 35%,
66 props.get(vars.$colors, --bg) 85%
67 );
68
69 animation-name: footer-l1-ani;
70 //animation-fill-mode: both;
71 animation-duration: 1ms;
72 animation-timeline: view(block 0% 0%);
73 animation-timing-function: linear;
74 }
75
76 @include bem.elem("banner-img") {
77 position: relative;
78 z-index: 2;
79 flex: 0 0 auto;
80 display: block;
81 block-size: 100%;
82 margin-inline: auto;
83 aspect-ratio: 3840 / 1600;
84 }
85
86 @include bem.elem("banner-bg") {
87 position: absolute;
88 inset-inline-start: 50%;
89 inset-block-end: 0;
90 z-index: 1;
91 display: block;
92 inline-size: 100%;
93 block-size: 100%;
94 transform: translateX(-50%);
95 }
96
97 @include bem.elem("content-wrap") {
98 padding-block-end: props.get(iro.$core--size--400);
99 background-color: props.get(iro.$core--transparent-colors, --black, --900);
100 }
101
102 @include bem.elem("content") {
103 display: flex;
104 flex-wrap: wrap;
105 gap: props.get(iro.$core--size--600);
106 }
107
108 @include bem.elem("sections") {
109 display: flex;
110 flex-wrap: wrap;
111 gap: props.get(iro.$core--size--400) props.get(iro.$core--size--800);
112 }
113
114 @include bem.elem("table") {
115 display: grid;
116 grid-template-columns: auto auto auto auto;
117 column-gap: props.get(iro.$core--size--200);
118 }
119
120 @include bem.elem("table-row") {
121 display: contents;
122 }
123
124 @include bem.elem("divider") {
125 display: none;
126 }
127
128 @include bem.elem("logo") {
129 margin-block: auto;
130 margin-inline-start: auto;
131 }
132
133 @include iro.media--media("<=md") {
134 @include bem.elem("content") {
135 flex-direction: column;
136 align-items: center;
137 }
138
139 @include bem.elem("divider") {
140 display: flex;
141 inline-size: 100%;
142 }
143
144 @include bem.elem("logo") {
145 margin-inline-start: 0;
146 }
147 }
148
149 @include iro.media--media("<=sm") {
150 transform: translateY(calc(-1 * props.get(iro.$o-navbar--block-size)));
151
152 @include bem.elem("banner-img") {
153 transform: translateX(max(0rem, 19rem - 50vw));
154 }
155 }
156 }
157}
diff --git a/src/styles/components/_site-footer.vars.scss b/src/styles/components/_site-footer.vars.scss
new file mode 100644
index 0000000..0b2f968
--- /dev/null
+++ b/src/styles/components/_site-footer.vars.scss
@@ -0,0 +1,34 @@
1@use "pkg:iro-design/core.vars" as core;
2@use "pkg:iro-design/objects/backdrop.vars" as backdrop;
3@use "pkg:iro-sass/props";
4
5$colors: props.def(
6 --site-footer,
7 (
8 --bg-from: props.get(core.$theme, --bg-l1),
9 --bg: props.get(core.$theme, --bg-l1),
10 --star: transparent,
11 --cloud: props.get(core.$theme, --base, --50),
12 --l1: props.get(core.$theme, --bg-base),
13 --dragon-awake: props.get(core.$transparent-colors, --black, --900),
14 --dragon-asleep--eye: transparent,
15 --dragon-asleep: transparent,
16 --fg: props.get(core.$transparent-colors, --black, --900),
17 ),
18 "color"
19);
20
21$colors--dark: props.def(
22 --site-footer,
23 (
24 --bg: props.get(core.$theme, --bg-l2),
25 --star: #fff,
26 --cloud: props.get(core.$theme, --base, --100),
27 --l1: props.get(core.$theme, --bg-l1),
28 --dragon-awake: transparent,
29 --dragon-asleep--eye: #fff,
30 --dragon-asleep: #000,
31 --fg: #000,
32 ),
33 "dark"
34);
diff --git a/src/styles/components/_site-header.scss b/src/styles/components/_site-header.scss
new file mode 100644
index 0000000..d23fa7c
--- /dev/null
+++ b/src/styles/components/_site-header.scss
@@ -0,0 +1,136 @@
1@use "sass:math";
2@use "sass:meta";
3
4@use "pkg:iro-design" as iro;
5@use "pkg:iro-sass/bem";
6@use "pkg:iro-sass/props";
7
8@forward "site-header.vars";
9@use "site-header.vars" as vars;
10
11@mixin styles {
12 @include iro.props--materialize-at-root(meta.module-variables("vars"));
13
14 @include bem.component("site-header") {
15 position: sticky;
16 inset-block-start: 0;
17 z-index: props.get(vars.$z-index);
18 display: flex;
19 gap: props.get(vars.$spacing);
20 align-items: center;
21 line-height: 1.4;
22 block-size: props.get(vars.$block-size);
23 padding-inline: props.get(iro.$l-container--pad-i);
24 background-color: props.get(iro.$core--theme, --bg-l1);
25 overflow: hidden;
26 transition:
27 background-color 0.2s ease,
28 opacity 0.2s ease,
29 transform 0.2s ease,
30 visibility 0s linear 0.2s;
31
32 @include bem.at-theme("js") {
33 background-color: transparent;
34 }
35
36 @include bem.modifier("hidden") {
37 margin-block-start: calc(-1 * props.get(vars.$block-size));
38 opacity: 0;
39 visibility: hidden;
40 transform: translateY(-0.2em);
41
42 @include bem.is("scroll") {
43 transition:
44 background-color 0.2s ease,
45 opacity 0.2s ease,
46 transform 0.2s ease,
47 visibility 0s linear;
48 visibility: visible;
49 opacity: 1;
50 transform: none;
51 }
52 }
53
54 @include bem.modifier("end") {
55 background-color: props.get(iro.$core--transparent-colors, --black, --900);
56 }
57
58 @include bem.is("scroll") {
59 background-color: props.get(iro.$core--theme, --base, --50);
60 }
61
62 @include bem.elem("logo") {
63 @include bem.modifier("sm") {
64 display: none;
65 }
66 }
67
68 @include bem.elem("nav", "back") {
69 margin-inline-end: calc(-1 * props.get(vars.$nav-pull));
70 }
71
72 @include bem.elem("back") {
73 display: none;
74 }
75
76 @include bem.elem("spacer") {
77 margin-inline-start: auto;
78 }
79
80 @include bem.elem("slot") {
81 display: contents;
82
83 @include bem.modifier("sm") {
84 display: none;
85 }
86 }
87
88 @include iro.media--media("<=sm") {
89 @include bem.elem("logo") {
90 display: none;
91
92 @include bem.modifier("sm") {
93 display: block;
94 }
95 }
96
97 @include bem.elem("nav") {
98 display: none;
99 }
100
101 @include bem.elem("back") {
102 display: block;
103 }
104
105 @include bem.elem("slot") {
106 @include bem.modifier("end") {
107 display: none;
108 }
109
110 @include bem.modifier("sm") {
111 display: contents;
112 }
113 }
114
115 @include bem.modifier("end") {
116 @include bem.elem("logo") {
117 display: block;
118
119 @include bem.modifier("sm") {
120 display: none;
121 }
122 }
123
124 @include bem.elem("slot") {
125 @include bem.modifier("sm") {
126 display: none;
127 }
128 }
129 }
130 }
131
132 @include iro.media--media("height<=500px") {
133 position: relative;
134 }
135 }
136}
diff --git a/src/styles/components/_site-header.vars.scss b/src/styles/components/_site-header.vars.scss
new file mode 100644
index 0000000..282e944
--- /dev/null
+++ b/src/styles/components/_site-header.vars.scss
@@ -0,0 +1,14 @@
1@use "pkg:iro-design/core.vars" as core;
2@use "pkg:iro-design/objects/backdrop.vars" as backdrop;
3@use "pkg:iro-sass/props";
4
5$z-index: props.def(--c-site-header--z-index, calc(props.get(backdrop.$z-index) - 10));
6$block-size: props.def(--c-site-header--block-size, props.get(core.$size--800));
7$spacing: props.def(--c-site-header--spacing, props.get(core.$size--500));
8$nav-pull: props.def(--c-site-header--nav-pull, props.get(core.$size--300));
9
10$spacing--md: props.def(--c-site-header--spacing, props.get(core.$size--400), "md");
11$nav-pull--md: props.def(--c-site-header--nav-pull, props.get(core.$size--200), "md");
12
13$block-size--sm: props.def(--c-site-header--block-size, props.get(core.$size--700), "sm");
14$spacing--sm: props.def(--c-site-header--spacing, props.get(core.$size--300), "sm");
diff --git a/src/styles/components/_site-nav.scss b/src/styles/components/_site-nav.scss
new file mode 100644
index 0000000..99a8afc
--- /dev/null
+++ b/src/styles/components/_site-nav.scss
@@ -0,0 +1,30 @@
1@use "sass:math";
2@use "sass:meta";
3
4@use "pkg:iro-design" as iro;
5@use "pkg:iro-sass/bem";
6@use "pkg:iro-sass/props";
7
8@mixin styles {
9 @include bem.component("site-nav-placeholder") {
10 block-size: props.get(iro.$o-navbar--block-size);
11
12 @include iro.media--media(">sm") {
13 display: none;
14 }
15 }
16
17 @include bem.component("site-nav") {
18 position: fixed;
19 inset-block-end: 0;
20 inset-inline: 0;
21 z-index: calc(props.get(iro.$o-backdrop--z-index) - 10);
22 background-color: props.get(iro.$core--transparent-colors, --black, --900);
23 //border-block-start: 1px solid props.get(iro.$core--theme, --border-mute);
24 overflow-x: auto;
25
26 @include iro.media--media(">sm") {
27 display: none;
28 }
29 }
30}
diff --git a/src/styles/components/_subtitle.scss b/src/styles/components/_subtitle.scss
new file mode 100644
index 0000000..e9a40bb
--- /dev/null
+++ b/src/styles/components/_subtitle.scss
@@ -0,0 +1,11 @@
1@use "pkg:iro-sass/bem";
2@use "pkg:iro-sass/props";
3@use "pkg:iro-design" as iro;
4
5@mixin styles {
6 @include bem.component("subtitle") {
7 margin-block-start: props.get(iro.$s-implicit--paragraph--margin-bs);
8 color: props.get(iro.$core--theme, --text-mute-more);
9 font-size: props.get(iro.$core--font-size--300);
10 }
11}
diff --git a/src/styles/components/_visited-indicator.scss b/src/styles/components/_visited-indicator.scss
new file mode 100644
index 0000000..7aa1f38
--- /dev/null
+++ b/src/styles/components/_visited-indicator.scss
@@ -0,0 +1,27 @@
1@use "pkg:iro-sass/bem";
2@use "pkg:iro-sass/props";
3@use "pkg:iro-design" as iro;
4
5@mixin styles {
6 @include bem.component("with-visited-indicator") {
7 @include bem.elem("indicator") {
8 block-size: 1.4em;
9 color: props.get(iro.$core--theme, --text);
10
11 &::after {
12 content: "";
13 display: block;
14 block-size: 100%;
15 inline-size: props.get(iro.$core--border-width--thick);
16 border-radius: 10em;
17 background-color: currentColor;
18 }
19 }
20
21 &:visited {
22 @include bem.elem("indicator") {
23 color: props.get(iro.$core--theme, --border-mute);
24 }
25 }
26 }
27}
diff --git a/src/styles/objects/_deflist.scss b/src/styles/objects/_deflist.scss
new file mode 100644
index 0000000..63ba1b9
--- /dev/null
+++ b/src/styles/objects/_deflist.scss
@@ -0,0 +1,25 @@
1@use "sass:list";
2
3@use "pkg:iro-sass/bem";
4@use "pkg:iro-sass/props";
5@use "pkg:iro-design" as iro;
6
7@mixin styles {
8 @include bem.object("deflist") {
9 display: grid;
10 grid-template-columns: auto 1fr;
11 gap: props.get(iro.$core--size--50) props.get(iro.$core--size--400);
12 padding: 0;
13 margin-block: props.get(iro.$s-implicit--paragraph--margin-bs) 0;
14 margin-inline: 0;
15
16 dt {
17 font-weight: bold;
18 color: props.get(iro.$core--theme, --heading);
19 }
20
21 dd {
22 margin: 0;
23 }
24 }
25}
diff --git a/src/styles/objects/_message.scss b/src/styles/objects/_message.scss
new file mode 100644
index 0000000..a7c9996
--- /dev/null
+++ b/src/styles/objects/_message.scss
@@ -0,0 +1,216 @@
1@use "sass:meta";
2
3@use "pkg:iro-design" as iro;
4@use "pkg:iro-sass/bem";
5@use "pkg:iro-sass/props";
6
7@forward "message.vars";
8@use "message.vars" as vars;
9
10@mixin styles {
11 @include iro.props--materialize-at-root(meta.module-variables("vars"));
12
13 @include bem.object("message") {
14 display: grid;
15 position: relative;
16 grid-template-rows: auto 1fr auto;
17 grid-template-columns:
18 auto calc(props.get(vars.$avatar-size) + props.get(vars.$spacing-x))
19 minmax(0, 1fr);
20 grid-template-areas:
21 "line-from avatar main"
22 "line-from line-to main"
23 "line-from replies replies";
24
25 & + & {
26 padding-block-start: props.get(vars.$spacing-outer);
27 }
28
29 @include bem.elem("line-from") {
30 grid-area: line-from;
31 position: relative;
32 inline-size: calc(props.get(vars.$avatar-size) + props.get(vars.$spacing-x));
33
34 &::before,
35 &::after {
36 content: "";
37 display: block;
38 position: absolute;
39 inset-block-start: 0;
40 inset-inline-start: props.get(vars.$line-offset);
41 }
42
43 &::before {
44 inline-size: props.get(vars.$line-width);
45 block-size: calc(100% + props.get(vars.$spacing-outer));
46 background-color: props.get(vars.$colors, --line);
47 }
48
49 &::after {
50 inline-size: calc(
51 props.get(vars.$avatar-size) - props.get(vars.$line-offset) + props.get(vars.$spacing-x)
52 );
53 block-size: calc(0.5 * (props.get(vars.$avatar-size) - props.get(vars.$line-width)));
54 border-block-end: props.get(vars.$line-width) solid props.get(vars.$colors, --line);
55 border-inline-start: props.get(vars.$line-width) solid props.get(vars.$colors, --line);
56 border-radius: 0 0 0 props.get(vars.$line-curve);
57 }
58
59 @include bem.modifier("last") {
60 &::before {
61 block-size: 0;
62 }
63 }
64
65 @include bem.sibling-elem("avatar") {
66 &::before {
67 inset-block-start: 50%;
68 inset-block-end: auto;
69 inset-inline-start: 0;
70 inline-size: 50%;
71 block-size: props.get(vars.$line-width);
72 transform: translateY(-50%);
73 }
74 }
75 }
76
77 @include bem.elem("line-to") {
78 grid-area: line-to;
79 display: block;
80 inline-size: props.get(vars.$line-width);
81 margin-inline-start: props.get(vars.$line-offset);
82 background-color: props.get(vars.$colors, --line);
83 }
84
85 @include bem.elem("avatar") {
86 position: relative;
87 grid-area: avatar;
88 inline-size: props.get(vars.$avatar-size);
89 block-size: props.get(vars.$avatar-size);
90 margin-inline-end: props.get(vars.$spacing-x);
91
92 &::before,
93 &::after {
94 content: "";
95 display: block;
96 position: absolute;
97 z-index: -1;
98 inset-inline-start: props.get(vars.$line-offset);
99 inline-size: props.get(vars.$line-width);
100 block-size: 50%;
101 background-color: props.get(vars.$colors, --line);
102 }
103
104 &::before {
105 inset-block-start: 0;
106 }
107
108 &::after {
109 inset-block-end: 0;
110 }
111
112 @include bem.modifier("last") {
113 &::after {
114 display: none;
115 }
116 }
117 }
118
119 @include bem.elem("avatar-dot") {
120 grid-area: avatar;
121 position: absolute;
122 inset-block-start: 50%;
123 inset-inline-start: 50%;
124 inline-size: props.get(vars.$dot-radius);
125 block-size: props.get(vars.$dot-radius);
126 transform: translate(-50%, -50%);
127 border: props.get(vars.$dot-width) solid props.get(vars.$colors, --dot, --border);
128 border-radius: 1em;
129 background-color: props.get(vars.$colors, --dot, --bg);
130 }
131
132 @include bem.elem("meta") {
133 white-space: nowrap;
134 color: props.get(vars.$colors, --meta);
135 }
136
137 @include bem.elem("main") {
138 position: relative;
139 grid-area: main;
140 }
141
142 @include bem.elem("replies") {
143 grid-area: replies;
144
145 &::before {
146 content: "";
147 grid-area: line;
148 display: block;
149 inline-size: props.get(vars.$line-width);
150 block-size: props.get(vars.$spacing-inner);
151 margin-inline-start: props.get(vars.$line-offset);
152 background-color: props.get(vars.$colors, --line);
153 }
154 }
155
156 @include bem.elem("header") {
157 line-height: 1.4;
158
159 @include bem.next-elem("body") {
160 padding-block-start: props.get(vars.$spacing-y);
161 }
162 }
163
164 @include bem.elem("body") {
165 word-wrap: break-word;
166
167 &::before {
168 content: "";
169 display: block;
170 margin-block: -100em 100em;
171 }
172 }
173
174 @include bem.elem("attachments") {
175 display: grid;
176 grid-template-columns: repeat(auto-fill, minmax(props.get(vars.$attachment-width), 1fr));
177 grid-auto-rows: auto;
178 gap: props.get(vars.$spacing-x);
179 margin-block-start: props.get(vars.$spacing-attachments);
180 }
181
182 @include bem.elem("attachment") {
183 aspect-ratio: 3 / 2;
184 background-color: props.get(vars.$colors, --attachment, --bg);
185 }
186
187 @include bem.modifier("expanded") {
188 grid-template-rows: auto 1fr auto;
189 grid-template-columns: calc(props.get(vars.$avatar-size) + props.get(vars.$spacing-x)) minmax(
190 0,
191 1fr
192 );
193 grid-template-areas:
194 "avatar header"
195 "body body"
196 "attachments attachments";
197
198 @include bem.elem("header") {
199 grid-area: header;
200 align-self: center;
201 }
202
203 @include bem.elem("main") {
204 display: contents;
205 }
206
207 @include bem.elem("body") {
208 grid-area: body;
209 }
210
211 @include bem.elem("attachments") {
212 grid-area: attachments;
213 }
214 }
215 }
216}
diff --git a/src/styles/objects/_message.vars.scss b/src/styles/objects/_message.vars.scss
new file mode 100644
index 0000000..f394894
--- /dev/null
+++ b/src/styles/objects/_message.vars.scss
@@ -0,0 +1,39 @@
1@use "pkg:iro-design/core.vars" as core;
2@use "pkg:iro-design/objects/avatar.vars" as avatar;
3@use "pkg:iro-sass/props";
4
5$avatar-size: props.def(--o-message--avatar-size, props.get(avatar.$size));
6
7$dot-radius: props.def(--o-message--dot-radius, 6px);
8$dot-width: props.def(--o-message--dot-width, 4px);
9
10$spacing-x: props.def(--o-message--spacing-x, props.get(core.$size--200));
11$spacing-y: props.def(--o-message--spacing-y, props.get(core.$size--150));
12$spacing-inner: props.def(--o-message--spacing-inner, props.get(core.$size--500));
13$spacing-outer: props.def(--o-message--spacing-outer, props.get(core.$size--700));
14$spacing-attachments: props.def(--o-message--spacing-attachments, props.get(core.$size--200));
15
16$line-width: props.def(--o-message--line-width, props.get(core.$border-width--medium));
17$line-offset: props.def(
18 --o-message--line-offset,
19 calc(0.5 * props.get($avatar-size) - props.get($line-width))
20);
21$line-curve: props.def(--o-message--line-curve, props.get(core.$size--250));
22
23$attachment-width: props.def(--o-message--attachment-width, props.get(core.$size--2000));
24
25$colors: props.def(
26 --o-message,
27 (
28 --meta: props.get(core.$theme, --text-mute-more),
29 --line: props.get(core.$theme, --border),
30 --dot: (
31 --border: props.get(core.$theme, --text-mute-more),
32 --bg: props.get(core.$theme, --bg-base),
33 ),
34 --attachment: (
35 --bg: props.get(core.$theme, --border-mute),
36 ),
37 ),
38 "color"
39);
diff --git a/src/styles/objects/_placeholder-box.scss b/src/styles/objects/_placeholder-box.scss
new file mode 100644
index 0000000..9be3dc8
--- /dev/null
+++ b/src/styles/objects/_placeholder-box.scss
@@ -0,0 +1,12 @@
1@use "pkg:iro-sass/bem";
2@use "pkg:iro-sass/props";
3@use "pkg:iro-design" as iro;
4
5@mixin styles {
6 @include bem.object("placeholder-box") {
7 padding: props.get(iro.$core--size--250) props.get(iro.$core--size--300);
8 border: props.get(iro.$core--border-width--thin) dashed
9 props.get(iro.$core--theme, --border, --mute);
10 border-radius: props.get(iro.$core--rounding);
11 }
12}
diff --git a/src/styles/objects/_toc.scss b/src/styles/objects/_toc.scss
new file mode 100644
index 0000000..aefb0c5
--- /dev/null
+++ b/src/styles/objects/_toc.scss
@@ -0,0 +1,27 @@
1@use "sass:list";
2
3@use "pkg:iro-sass/bem";
4@use "pkg:iro-sass/props";
5@use "pkg:iro-design" as iro;
6
7@mixin styles {
8 @include bem.object("toc") {
9 @include bem.elem("item") {
10 @include bem.next-twin-elem {
11 margin-block-start: props.get(iro.$core--size--50);
12 }
13
14 @include bem.is("active") {
15 @include bem.elem("item-link") {
16 font-weight: bold;
17 text-decoration-line: none;
18 color: props.get(iro.$core--theme, --heading);
19
20 &:hover {
21 text-decoration-line: underline;
22 }
23 }
24 }
25 }
26 }
27}
diff --git a/src/styles/style.scss b/src/styles/style.scss
new file mode 100644
index 0000000..75d123f
--- /dev/null
+++ b/src/styles/style.scss
@@ -0,0 +1,53 @@
1@use "./common" as iro;
2
3@include iro.layers--styles;
4@include iro.core--styles;
5@include iro.styles;
6
7@include iro.s-implicit--styles;
8@include iro.s-blockquotes--styles;
9@include iro.s-body--styles;
10@include iro.s-code--styles;
11@include iro.s-figures--styles;
12@include iro.s-headings--styles;
13@include iro.s-lists--styles;
14@include iro.s-links--styles;
15
16@include iro.l-card-list--styles;
17@include iro.l-container--styles;
18@include iro.l-flex--styles;
19@include iro.l-hlist--styles;
20@include iro.l-media--styles;
21@include iro.l-split-view--styles;
22
23@include iro.o-alert--styles;
24@include iro.o-avatar--styles;
25@include iro.o-backdrop--styles;
26@include iro.o-button--styles;
27@include iro.o-card--styles;
28@include iro.o-deflist--styles;
29@include iro.o-dialog--styles;
30@include iro.o-divider--styles;
31@include iro.o-emoji--styles;
32@include iro.o-figure--styles;
33@include iro.o-heading--styles;
34@include iro.o-icon--styles;
35@include iro.o-thumbnail--styles;
36@include iro.o-lightbox--styles;
37@include iro.o-navbar--styles;
38@include iro.o-placeholder-box--styles;
39@include iro.o-side-nav--styles;
40@include iro.o-tabbar--styles;
41@include iro.o-text-field--styles;
42@include iro.o-toc--styles;
43
44@include iro.c-home-banner--styles;
45@include iro.c-logo--styles;
46@include iro.c-site-header--styles;
47@include iro.c-site-nav--styles;
48@include iro.c-site-footer--styles;
49@include iro.c-subtitle--styles;
50@include iro.c-visited-indicator--styles;
51
52@include iro.utils--styles;
53@include iro.themes--styles;
diff --git a/src/ui/base/BaseLayout.tsx b/src/ui/base/BaseLayout.tsx
new file mode 100644
index 0000000..c57d41e
--- /dev/null
+++ b/src/ui/base/BaseLayout.tsx
@@ -0,0 +1,134 @@
1// @ts-types="corvos/mod.ts"
2import { getGenerator, type Select } from "corvos/mod.ts";
3
4import site from "@/corvos.config.ts";
5
6import { SiteFooter } from "../components/SiteFooter.tsx";
7import { SiteNav } from "../components/SiteNav.tsx";
8import { Lightbox } from "../objects/Lightbox.tsx";
9
10export interface Props {
11 page: Select<typeof site, ".html">;
12 image?: string;
13 theme?: "l1" | "l2" | null;
14 children?: JSX.Element;
15 class?: unknown;
16}
17
18export const BaseLayout = site.$.component(
19 async ({ page, theme = "l1", image: imageUrl, children, class: cls }: Props, pages) => {
20 const query = site.$.query(pages);
21
22 const stylesheet = await site.load("styles/style.scss");
23 const script = await site.load("scripts/script.ts");
24 const fonts = await site.glob("assets/**.woff2");
25 const favicon = await site.load("assets/favicon.svg");
26 const image = await site.load(
27 imageUrl ?? "https://strapi.volpeon.ink/uploads/icon_7999cf625e.png",
28 );
29
30 const feeds = query.type(".rss").many();
31
32 return (
33 <>
34 {{ __html: "<!DOCTYPE html>" }}
35 <html lang="en">
36 <head>
37 <meta charset="utf-8" />
38 <meta content="light dark" name="color-scheme" />
39 <meta content="width=device-width, initial-scale=1" name="viewport" />
40 <meta content="scale" name="text-scale" />
41 <link href={site.$.url("sitemap.xml")} rel="sitemap" />
42 <link href={favicon.url} rel="icon" type="image/svg+xml" />
43 {fonts.map((font) => (
44 <link as="font" href={font.url} rel="preload" type="font/woff2" />
45 ))}
46
47 <style>@layer scope, theme, object, layout, component, utility;</style>
48 <link rel="stylesheet" href={stylesheet.url} />
49 <script src={script.url} defer />
50 {feeds.map((feed) => (
51 <link
52 rel="alternate"
53 href={feed.url}
54 title={feed.data.title}
55 type="application/rss+xml"
56 />
57 ))}
58 <title>
59 {page.data.title}
60 {page.data.type === "home" ? "" : " · Corvos"}
61 </title>
62
63 <meta property="og:type" content="website" />
64 <meta property="og:site_name" content="Corvos" />
65 <meta property="og:locale" content="en" />
66 {"description" in page.data && page.data.description && (
67 <>
68 <meta property="og:description" content={page.data.description} />
69 <meta name="description" content={page.data.description} />
70 </>
71 )}
72 <meta property="og:url" content={site.$.url("", true)} />
73 <meta property="og:image" content={image.url} />
74 <meta name="twitter:card" content={imageUrl ? "summary_large_image" : "summary"} />
75 <meta name="generator" content={getGenerator()} />
76 </head>
77 <body class:list={["t-no-js", { [`t-${theme}`]: theme }, cls]}>
78 {children}
79
80 <SiteFooter />
81 <SiteNav url={page.url} />
82 <Lightbox />
83 <svg
84 version="1.1"
85 xmlns="http://www.w3.org/2000/svg"
86 xmlns:xlink="http://www.w3.org/1999/xlink"
87 class="u-d-none"
88 >
89 <title>Loading indicator</title>
90 <symbol id="loading-indicator" viewBox="0 0 50 50">
91 <circle
92 cx="25"
93 cy="25"
94 r="20"
95 fill="none"
96 stroke="currentColor"
97 stroke-width=".4em"
98 stroke-linecap="round"
99 stroke-dasharray="1 200"
100 stroke-dashoffset="0"
101 stroke-miterlimit="10"
102 transform-origin="center center"
103 >
104 <animate
105 attributeName="stroke-dasharray"
106 keyTimes="0;0.5;1"
107 values="1 200;89 200;89 200"
108 dur="2s"
109 repeatCount="indefinite"
110 />
111 <animate
112 attributeName="stroke-dashoffset"
113 keyTimes="0;0.5;1"
114 values="0;-35;-124"
115 dur="2s"
116 repeatCount="indefinite"
117 />
118 <animateTransform
119 attributeName="transform"
120 type="rotate"
121 from="0"
122 to="360"
123 dur="1.3s"
124 repeatCount="indefinite"
125 />
126 </circle>
127 </symbol>
128 </svg>
129 </body>
130 </html>
131 </>
132 );
133 },
134);
diff --git a/src/ui/components/Logo.tsx b/src/ui/components/Logo.tsx
new file mode 100644
index 0000000..a9ec9be
--- /dev/null
+++ b/src/ui/components/Logo.tsx
@@ -0,0 +1,34 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3import site from "@/corvos.config.ts";
4
5export type Props = HTMLAttributes<"a"> & {
6 size?: "sm" | "lg";
7 variant?: "static-black" | "static-white";
8};
9
10export const Logo = async ({ size, variant, class: cls, ...props }: Props) => {
11 const symbols = await site.load("assets/symbols.svg");
12
13 return (
14 <a
15 aria-label="Home"
16 class:list={[
17 "c-logo",
18 {
19 [`c-logo--${size}`]: size,
20 [`c-logo--${variant}`]: variant,
21 },
22 cls,
23 ]}
24 href={site.$.url()}
25 rel="home"
26 {...props}
27 >
28 <svg class="c-logo__img">
29 <title>Logo</title>
30 <use href={`${symbols.url}#logo${size === "sm" ? "-sm" : ""}`} />
31 </svg>
32 </a>
33 );
34};
diff --git a/src/ui/components/ReleaseCard.tsx b/src/ui/components/ReleaseCard.tsx
new file mode 100644
index 0000000..bffe256
--- /dev/null
+++ b/src/ui/components/ReleaseCard.tsx
@@ -0,0 +1,47 @@
1import type { Select } from "corvos";
2
3import type site from "@/corvos.config.ts";
4
5import { CardBody } from "$/ui/objects/CardBody.tsx";
6import { Heading } from "$/ui/objects/Heading.tsx";
7
8import { Card, type Props as CardProps } from "../objects/Card.tsx";
9
10export type Props = CardProps<"a"> & {
11 "heading-level": "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
12 "page": Select<typeof site, ".html", { type: "release" }>;
13};
14
15export const ReleaseCard = ({ "heading-level": headingLevel, page, ...props }: Props) => {
16 return (
17 <Card
18 as="a"
19 class="c-with-visited-indicator h-entry u-url"
20 horizontal
21 href={page.url}
22 {...props}
23 >
24 <CardBody class="c-with-visited-indicator__indicator u-pie-0" />
25
26 <CardBody main>
27 <Heading as={headingLevel} class="p-name">
28 <span class="u-mie-100">{page.data.title}</span>
29 </Heading>
30 </CardBody>
31
32 <CardBody as="small" class="u-c-mute u-d-none@sm-lo">
33 <ul class="l-hlist l-hlist--separated">
34 <li class="l-hlist__item">
35 <time class="dt-published" datetime={page.data.date.toISOString()}>
36 {page.data.date.toLocaleDateString(undefined, {
37 day: "numeric",
38 month: "short",
39 year: "numeric",
40 })}
41 </time>
42 </li>
43 </ul>
44 </CardBody>
45 </Card>
46 );
47};
diff --git a/src/ui/components/SiteFooter.tsx b/src/ui/components/SiteFooter.tsx
new file mode 100644
index 0000000..8bada2e
--- /dev/null
+++ b/src/ui/components/SiteFooter.tsx
@@ -0,0 +1,39 @@
1import site from "@/corvos.config.ts";
2
3import { Container } from "../layouts/Container.tsx";
4import { Divider } from "../objects/Divider.tsx";
5import { Logo } from "./Logo.tsx";
6
7export const SiteFooter = async () => {
8 const symbols = await site.load("assets/symbols.svg");
9
10 return (
11 <footer class="c-site-footer">
12 <div class="c-site-footer__banner">
13 <svg class="c-site-footer__banner-img">
14 <use href={`${symbols.url}#site-footer`} />
15 </svg>
16
17 <svg class="c-site-footer__banner-bg">
18 <use href={`${symbols.url}#site-footer-bg`} />
19 </svg>
20 </div>
21
22 <div class="c-site-footer__content-wrap t-static-white">
23 <Container class="c-site-footer__content" fixed="150" pad-block pad-inline>
24 <div class="c-site-footer__sections">
25 <section class="c-site-footer__section s-links s-links--mark-external s-links--static-white">
26 Developed by <a href="https://volpeon.ink/">Volpeon</a>
27 </section>
28 </div>
29
30 <Divider class="c-site-footer__divider" level="quiet" variant="static-white" />
31
32 <section class="c-site-footer__logo">
33 <Logo size="lg" variant="static-white" />
34 </section>
35 </Container>
36 </div>
37 </footer>
38 );
39};
diff --git a/src/ui/components/SiteHeader.tsx b/src/ui/components/SiteHeader.tsx
new file mode 100644
index 0000000..17831a1
--- /dev/null
+++ b/src/ui/components/SiteHeader.tsx
@@ -0,0 +1,121 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3import { Icon } from "$/ui/objects/Icon.tsx";
4
5import { nav } from "../../data/nav.ts";
6import { ActionButton } from "../objects/ActionButton.tsx";
7import { Navbar } from "../objects/Navbar.tsx";
8import { NavbarItem } from "../objects/NavbarItem.tsx";
9import { Logo } from "./Logo.tsx";
10
11export type Props = HTMLAttributes<"header"> & {
12 "url": string;
13 "mode"?: "compact" | "none";
14 "at-end"?: boolean;
15 "hidden"?: boolean;
16 "sm"?: JSX.Element;
17 "end"?: JSX.Element;
18 "children"?: JSX.Element;
19};
20
21export const SiteHeader = ({
22 children,
23 sm,
24 url,
25 mode,
26 "at-end": atEnd,
27 hidden,
28 end,
29 class: cls,
30 ...props
31}: Props) => {
32 const navItems = nav.map((item) => ({
33 ...item,
34 selected: new RegExp(`^${item.url.replace("/#", "/")}`).test(url),
35 }));
36
37 const parent = navItems
38 .filter((item) => item.url !== url)
39 .reduce(
40 (parent: (typeof navItems)[number] | undefined, item) =>
41 item.selected && item.url.length > (parent?.url.length ?? 0) ? item : parent,
42 undefined,
43 );
44 const selected = navItems.reduce(
45 (sel, item) => (item.selected && item.url.length > sel.url.length ? item : sel),
46 {
47 icon: "",
48 label: "Back",
49 selected: true,
50 url: "/",
51 },
52 );
53
54 const variant = atEnd ? "static-white" : undefined;
55
56 return (
57 <header
58 class:list={[
59 "c-site-header",
60 "l-media",
61 { "c-site-header--end": atEnd, "c-site-header--hidden": hidden },
62 cls,
63 ]}
64 {...props}
65 >
66 <Logo class="c-site-header__logo l-media__block" variant={variant} />
67
68 {mode !== "none" && (
69 <>
70 <Navbar aria-label="Mainmenu" class="c-site-header__nav l-media__block" variant={variant}>
71 {mode === "compact" ? (
72 <NavbarItem
73 href={selected.url}
74 pre={<Icon icon="arrow-left" variant="bold" />}
75 selected
76 >
77 {selected.label}
78 </NavbarItem>
79 ) : (
80 navItems
81 .filter((item) => item.url !== "/" && !item.hidden)
82 .map((item) => (
83 <NavbarItem as="a" href={item.url} selected={item.selected}>
84 {item.label}
85 </NavbarItem>
86 ))
87 )}
88 </Navbar>
89
90 {parent && (
91 <ActionButton
92 align-block
93 class="c-site-header__back l-media__block"
94 href={parent.url}
95 icon={<Icon icon="arrow-left" variant="bold" />}
96 level="quiet"
97 pill
98 />
99 )}
100 </>
101 )}
102
103 {children}
104
105 {sm && <div class="c-site-header__slot c-site-header__slot--sm">{sm}</div>}
106
107 {end && (
108 <>
109 <div class="c-site-header__spacer" />
110 <div class="c-site-header__slot c-site-header__slot--end">{end}</div>
111 </>
112 )}
113
114 <Logo
115 class="c-site-header__logo c-site-header__logo--sm l-media__block"
116 size="sm"
117 variant={variant}
118 />
119 </header>
120 );
121};
diff --git a/src/ui/components/SiteNav.tsx b/src/ui/components/SiteNav.tsx
new file mode 100644
index 0000000..7518e3c
--- /dev/null
+++ b/src/ui/components/SiteNav.tsx
@@ -0,0 +1,38 @@
1import { Icon } from "$/ui/objects/Icon.tsx";
2
3import { nav } from "../../data/nav.ts";
4import { Navbar } from "../objects/Navbar.tsx";
5import { NavbarItem } from "../objects/NavbarItem.tsx";
6
7export interface Props {
8 url: string;
9}
10
11export const SiteNav = ({ url }: Props) => {
12 const navItems = nav
13 .filter((item) => !item.hidden)
14 .map((item) => ({
15 ...item,
16 selected: item.url === "/" ? item.url === url : new RegExp(`^${item.url}`).test(url),
17 }));
18
19 return (
20 <>
21 <div class="c-site-nav-placeholder"></div>
22
23 <footer class="c-site-nav">
24 <Navbar aria-label="Mainmenu" quiet variant="static-white">
25 {navItems.map((item) => (
26 <NavbarItem
27 href={item.url}
28 icon={<Icon icon={item.icon} variant={item.selected ? "fill" : "regular"} />}
29 selected={item.selected}
30 >
31 {item.label}
32 </NavbarItem>
33 ))}
34 </Navbar>
35 </footer>
36 </>
37 );
38};
diff --git a/src/ui/layouts/CardList.tsx b/src/ui/layouts/CardList.tsx
new file mode 100644
index 0000000..96ae642
--- /dev/null
+++ b/src/ui/layouts/CardList.tsx
@@ -0,0 +1,52 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3export type Props = HTMLAttributes<"ul"> & {
4 "layout"?:
5 | "merge"
6 | "grid"
7 | "grid-sm"
8 | "grid-xs"
9 | "flex"
10 | "flex-sm"
11 | "flex-xs"
12 | "masonry"
13 | "masonry-h"
14 | "masonry-h-sm";
15 "quiet"?: boolean;
16 "border"?: boolean;
17 "interactive"?: boolean;
18 "no-shadow"?: boolean;
19 "no-flush"?: boolean;
20 "keyboard-nav"?: boolean;
21};
22
23export const CardList = ({
24 layout,
25 quiet,
26 border,
27 interactive,
28 "no-shadow": noShadow,
29 "no-flush": noFlush,
30 "keyboard-nav": keyboardNav,
31 class: cls,
32 ...props
33}: Props) => (
34 <div
35 class:list={[
36 "l-card-list",
37 {
38 [`l-card-list--${layout}`]: layout,
39 "l-card-list--interactive": interactive,
40 "l-card-list--no-flush": noFlush,
41 "l-card-list--quiet": quiet,
42 "l-card-list--borderless": !border,
43 "l-card-list--shadow": layout === "merge" && !noShadow,
44 "js-keyboard-nav": keyboardNav,
45 },
46 cls,
47 ]}
48 data-keyboard-nav-item=".l-card-list__card"
49 data-keyboard-nav-mode="grid"
50 {...props}
51 />
52);
diff --git a/src/ui/layouts/Container.tsx b/src/ui/layouts/Container.tsx
new file mode 100644
index 0000000..7ce7ed5
--- /dev/null
+++ b/src/ui/layouts/Container.tsx
@@ -0,0 +1,33 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3export type Props<Tag extends HTMLTag> = Polymorphic<{
4 "as": Tag;
5 "pad-inline"?: boolean;
6 "pad-block"?: boolean;
7 "size"?: "sm";
8 "fixed"?: boolean | "150" | "200";
9}>;
10
11export const Container = <T extends HTMLTag = "div">({
12 as: Tag = "div" as T,
13 class: cls,
14 "pad-inline": padInline,
15 "pad-block": padBlock,
16 fixed,
17 size,
18 ...props
19}: Props<T>) => (
20 <Tag
21 class:list={[
22 "l-container",
23 {
24 "l-container--pad-i": padInline,
25 "l-container--pad-b": padBlock,
26 [`l-container--${size}`]: size,
27 [`l-container--fixed${typeof fixed === "boolean" ? "" : `-${fixed}`}`]: fixed,
28 },
29 cls,
30 ]}
31 {...props}
32 />
33);
diff --git a/src/ui/layouts/EmojiGrid.tsx b/src/ui/layouts/EmojiGrid.tsx
new file mode 100644
index 0000000..5dc9266
--- /dev/null
+++ b/src/ui/layouts/EmojiGrid.tsx
@@ -0,0 +1,40 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3export type Props = HTMLAttributes<"ul"> & {
4 id: string;
5 sprite: {
6 variants: { size: number; space: number }[];
7 wrap: number;
8 };
9 count: number;
10};
11
12export const EmojiGrid = ({ id, sprite, count, class: cls, ...props }: Props) => {
13 const size = sprite.variants[0]?.size ?? 48;
14 const space = sprite.variants[0]?.space ?? 2;
15 const { wrap } = sprite;
16
17 const src = `/emojis/${id}/sprite48.avif`;
18 const srcset = `/emojis/${id}/sprite48.avif 1x, /emojis/${id}/sprite96.avif 2x`;
19
20 const offsetX = (i: number) => -1 * (size + space) * (i % wrap);
21 const offsetY = (i: number) => -1 * (size + space) * Math.floor(i / wrap);
22
23 return (
24 <ul class:list={["l-emoji-grid", cls]} style={{ "--emoji-size": `${size}px` }} {...props}>
25 {[...Array(count).keys()].map((i) => (
26 <li>
27 <img
28 alt=""
29 class="l-emoji-grid__emoji"
30 src={src}
31 srcset={srcset}
32 width={size}
33 height={size}
34 style={{ "object-position": `${offsetX(i)}px ${offsetY(i)}px` }}
35 />
36 </li>
37 ))}
38 </ul>
39 );
40};
diff --git a/src/ui/layouts/HList.tsx b/src/ui/layouts/HList.tsx
new file mode 100644
index 0000000..c1d870a
--- /dev/null
+++ b/src/ui/layouts/HList.tsx
@@ -0,0 +1,44 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3import type { Props as ActionButtonProps } from "../objects/ActionButton.tsx";
4
5export type Props<Tag extends HTMLTag> = Polymorphic<{
6 "as"?: Tag;
7 "pill"?: boolean;
8 "icon"?: boolean;
9 "separated"?: boolean;
10 "size"?: "gapless" | "sm" | "lg" | "xl";
11 "buttons-size"?: ActionButtonProps<Tag>["size"];
12 "buttons"?: boolean;
13}>;
14
15export const HList = <T extends HTMLTag = "ul">({
16 as: Tag = "ul" as T,
17 pill,
18 icon,
19 separated,
20 size,
21 "buttons-size": buttonsSize,
22 buttons,
23 class: cls,
24 ...props
25}: Props<T>) => (
26 <Tag
27 class:list={[
28 "l-hlist",
29 {
30 "l-hlist--pill": pill,
31 "l-hlist--icon": icon,
32 "l-hlist--separated": separated,
33 [`l-hlist--${size}`]: size,
34 [`l-hlist--buttons-${buttonsSize}`]: buttonsSize,
35 "l-hlist--buttons": buttons,
36 },
37 "js-keyboard-nav",
38 cls,
39 ]}
40 data-keyboard-nav-item="li > :first-child"
41 data-keyboard-nav-mode="nav"
42 {...props}
43 />
44);
diff --git a/src/ui/layouts/SplitView.tsx b/src/ui/layouts/SplitView.tsx
new file mode 100644
index 0000000..81a55d1
--- /dev/null
+++ b/src/ui/layouts/SplitView.tsx
@@ -0,0 +1,27 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3export type Props<Tag extends HTMLTag> = Polymorphic<{
4 "as": Tag;
5 "gapless"?: boolean;
6 "wrap-reverse"?: boolean;
7}>;
8
9export const SplitView = <T extends HTMLTag = "div">({
10 as: Tag = "div" as T,
11 gapless,
12 "wrap-reverse": wrapReverse,
13 class: cls,
14 ...props
15}: Props<T>) => (
16 <Tag
17 class:list={[
18 "l-split-view",
19 {
20 "l-split-view--gapless": gapless,
21 "l-split-view--wrap-reverse": wrapReverse,
22 },
23 cls,
24 ]}
25 {...props}
26 />
27);
diff --git a/src/ui/layouts/SplitViewPanel.tsx b/src/ui/layouts/SplitViewPanel.tsx
new file mode 100644
index 0000000..7f04b44
--- /dev/null
+++ b/src/ui/layouts/SplitViewPanel.tsx
@@ -0,0 +1,33 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3export type Props<Tag extends HTMLTag> = Polymorphic<{
4 as: Tag;
5 sticky?: boolean;
6 side?: "25";
7 min?: "50" | "100" | "200";
8 fill?: boolean;
9}>;
10
11export const SplitViewPanel = <T extends HTMLTag = "div">({
12 as: Tag = "div" as T,
13 sticky,
14 side,
15 min,
16 fill,
17 class: cls,
18 ...props
19}: Props<T>) => (
20 <Tag
21 class:list={[
22 "l-split-view__panel",
23 {
24 "l-split-view__panel--sticky": sticky,
25 [`l-split-view__panel--side-${side}`]: side,
26 [`l-split-view__panel--min-${min}`]: min,
27 "l-split-view__panel--fill": fill,
28 },
29 cls,
30 ]}
31 {...props}
32 />
33);
diff --git a/src/ui/objects/ActionButton.tsx b/src/ui/objects/ActionButton.tsx
new file mode 100644
index 0000000..76f859f
--- /dev/null
+++ b/src/ui/objects/ActionButton.tsx
@@ -0,0 +1,61 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3export type Props<Tag extends HTMLTag> = Polymorphic<{
4 "as"?: Tag;
5 "label-as"?: string;
6 "pill"?: boolean;
7 "icon"?: JSX.Element;
8 "pre"?: JSX.Element;
9 "post"?: JSX.Element;
10 "highlighted"?: boolean;
11 "selected"?: boolean;
12 "wide"?: boolean;
13 "justify"?: boolean;
14 "align-block"?: boolean;
15 "level"?: "button" | "badge" | "quiet";
16 "variant"?: "accent" | "positive" | "negative" | "warning" | "static-black" | "static-white";
17 "size"?: "xs" | "sm" | "lg" | "xl";
18 "children"?: JSX.Element;
19}>;
20
21export const ActionButton = <T extends HTMLTag = "a">({
22 children,
23 as: Tag = "a" as T,
24 "label-as": Label = "span",
25 pill,
26 pre,
27 post,
28 wide,
29 justify,
30 icon,
31 "align-block": alignBlock,
32 highlighted,
33 selected,
34 level,
35 variant,
36 size,
37 class: cls,
38 ...props
39}: Props<T>) => (
40 <Tag
41 class:list={[
42 "o-button",
43 {
44 "o-button--pill": pill,
45 "o-button--wide": wide,
46 "o-button--justify": justify,
47 "o-button--icon": icon,
48 "o-button--align-block": alignBlock,
49 "is-highlighted": highlighted,
50 "is-selected": selected,
51 [`o-button--${level}`]: level && level !== "button",
52 [`o-button--${variant}`]: variant,
53 [`o-button--${size}`]: size,
54 },
55 cls,
56 ]}
57 {...props}
58 >
59 {pre} {icon ?? <Label class="o-button__label">{children}</Label>} {post}
60 </Tag>
61);
diff --git a/src/ui/objects/Alert.tsx b/src/ui/objects/Alert.tsx
new file mode 100644
index 0000000..f590e18
--- /dev/null
+++ b/src/ui/objects/Alert.tsx
@@ -0,0 +1,21 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3export type Props<Tag extends HTMLTag> = Polymorphic<{
4 as?: Tag;
5 icon?: JSX.Element;
6 variant?: "accent" | "positive" | "negative" | "warning";
7}>;
8
9export const Alert = <T extends HTMLTag = "div">({
10 children,
11 as: Tag = "div" as T,
12 variant,
13 icon,
14 class: cls,
15 ...props
16}: Props<T>) => (
17 <Tag class:list={["o-alert", { [`o-alert--${variant}`]: variant }, cls]} {...props}>
18 {icon && <span class="u-mie-100">{icon}</span>}
19 {children}
20 </Tag>
21);
diff --git a/src/ui/objects/Avatar.tsx b/src/ui/objects/Avatar.tsx
new file mode 100644
index 0000000..459af12
--- /dev/null
+++ b/src/ui/objects/Avatar.tsx
@@ -0,0 +1,69 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3import { StatusIndicator } from "$/ui/objects/StatusIndicator.tsx";
4
5export type Props<Tag extends HTMLTag> = Polymorphic<{
6 "as"?: Tag;
7 "src"?: string;
8 "icon"?: JSX.Element;
9 "children"?: JSX.Element;
10 "class"?: string;
11 "circle"?: boolean;
12 "placeholder"?: boolean;
13 "color"?: `hsl(${number}, ${number}%, ${number}%)`;
14 "color2"?: `hsl(${number}, ${number}%, ${number}%)`;
15 "color-angle"?: number | `${number}`;
16 "size"?: "xs" | "sm" | "lg" | "xl" | "xxl" | "xxxl";
17 "status"?: "accent" | "positive" | "negative" | "warning";
18}>;
19
20const hslRegex = /^hsl\(([\d.]+), ([\d.]+%), ([\d.]+%)\)$/;
21
22export const Avatar = <T extends HTMLTag = "div">({
23 children,
24 as: Tag = "div" as T,
25 src,
26 icon,
27 circle,
28 placeholder,
29 color,
30 color2,
31 "color-angle": colorAngle,
32 size,
33 status,
34 class: cls,
35 ...props
36}: Props<T>) => (
37 <Tag
38 class:list={[
39 "o-avatar",
40 {
41 "o-avatar--circle": circle,
42 "o-avatar--placeholder": placeholder,
43 "o-avatar--colored": color && !color2,
44 "o-avatar--colored-gradient": color && !!color2,
45 [`o-avatar--${size}`]: !!size,
46 },
47 cls,
48 ]}
49 style={{
50 "--o-avatar--bg-angle": colorAngle,
51 "--o-avatar--bg-color--h": color?.match(hslRegex)?.[1],
52 "--o-avatar--bg-color--l": color?.match(hslRegex)?.[3],
53 "--o-avatar--bg-color--s": color?.match(hslRegex)?.[2],
54 "--o-avatar--bg-color-2--h": color2?.match(hslRegex)?.[1],
55 "--o-avatar--bg-color-2--l": color2?.match(hslRegex)?.[3],
56 "--o-avatar--bg-color-2--s": color2?.match(hslRegex)?.[2],
57 }}
58 {...props}
59 >
60 {status && <StatusIndicator class="o-avatar__status" status={status} />}
61 {icon && <div class="o-avatar__icon u-ai-end u-jc-end">{icon}</div>}
62
63 {src ? (
64 <img alt="" class="o-avatar__content" src={src} />
65 ) : (
66 <span class="o-avatar__content">{children}</span>
67 )}
68 </Tag>
69);
diff --git a/src/ui/objects/Badge.tsx b/src/ui/objects/Badge.tsx
new file mode 100644
index 0000000..e98ef0d
--- /dev/null
+++ b/src/ui/objects/Badge.tsx
@@ -0,0 +1,19 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3import type { Props as ActionButtonProps } from "./ActionButton.tsx";
4
5import { ActionButton } from "./ActionButton.tsx";
6
7export type Props<Tag extends HTMLTag> = Polymorphic<{
8 as?: Tag;
9 variant?: ActionButtonProps<Tag>["variant"];
10 pill?: boolean;
11 selected?: boolean;
12 size?: ActionButtonProps<Tag>["size"];
13 children?: JSX.Element;
14}>;
15
16export const Badge = <T extends HTMLTag = "span">({ as = "span" as T, ...props }: Props<T>) => (
17 // deno-lint-ignore no-explicit-any
18 <ActionButton as={as} level="badge" {...(props as any)} />
19);
diff --git a/src/ui/objects/Body.tsx b/src/ui/objects/Body.tsx
new file mode 100644
index 0000000..149662c
--- /dev/null
+++ b/src/ui/objects/Body.tsx
@@ -0,0 +1,21 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3export type Props = HTMLAttributes<"div">;
4
5export const Body = ({ class: cls, ...props }: Props) => (
6 <div
7 class:list={[
8 "s-body",
9 "s-headings",
10 "s-headings--display",
11 "s-links",
12 "s-links--colored",
13 "s-code",
14 "s-figures",
15 "s-blockquotes",
16 "s-lists",
17 cls,
18 ]}
19 {...props}
20 />
21);
diff --git a/src/ui/objects/Button.tsx b/src/ui/objects/Button.tsx
new file mode 100644
index 0000000..68e1776
--- /dev/null
+++ b/src/ui/objects/Button.tsx
@@ -0,0 +1,26 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3import type { Props as ActionButtonProps } from "./ActionButton.tsx";
4
5import { ActionButton } from "./ActionButton.tsx";
6
7export type Props<Tag extends HTMLTag> = Polymorphic<{
8 as?: Tag;
9 variant?: "primary" | ActionButtonProps<Tag>["variant"];
10 size?: ActionButtonProps<Tag>["size"];
11 wide?: boolean;
12 justify?: boolean;
13 children?: JSX.Element;
14}>;
15
16export const Button = <T extends HTMLTag = "a">({ as = "a" as T, variant, ...props }: Props<T>) => (
17 <ActionButton
18 as={as}
19 label-as={variant ? "strong" : "span"}
20 pill
21 selected={!!variant}
22 variant={variant === "primary" ? undefined : variant}
23 // deno-lint-ignore no-explicit-any
24 {...(props as any)}
25 />
26);
diff --git a/src/ui/objects/Card.tsx b/src/ui/objects/Card.tsx
new file mode 100644
index 0000000..fd565e1
--- /dev/null
+++ b/src/ui/objects/Card.tsx
@@ -0,0 +1,47 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3export type Props<Tag extends HTMLTag> = Polymorphic<{
4 "as"?: Tag;
5 "list"?: boolean;
6 "quiet"?: boolean;
7 "thumbnail"?: boolean;
8 "border"?: boolean;
9 "no-shadow"?: boolean;
10 "horizontal"?: boolean;
11 "interactive"?: boolean;
12 "highlight"?: boolean;
13 "children"?: JSX.Element;
14}>;
15
16export const Card = <T extends HTMLTag = "article">({
17 as: Tag = "article" as T,
18 list,
19 quiet,
20 thumbnail,
21 border,
22 "no-shadow": noShadow,
23 horizontal,
24 interactive,
25 highlight,
26 class: cls,
27 ...props
28}: Props<T>) => (
29 <Tag
30 class:list={[
31 "o-card",
32 {
33 "l-card-list__card": list,
34 "o-card--borderless": !border,
35 "o-card--horizontal": horizontal,
36 "o-card--interactive": interactive,
37 "o-card--highlight": highlight,
38 "o-card--quiet": quiet,
39 "o-card--collapsible": Tag === "details",
40 "o-card--shadow": !noShadow,
41 "o-card--thumbnail": thumbnail,
42 },
43 cls,
44 ]}
45 {...props}
46 />
47);
diff --git a/src/ui/objects/CardBody.tsx b/src/ui/objects/CardBody.tsx
new file mode 100644
index 0000000..aa5df3d
--- /dev/null
+++ b/src/ui/objects/CardBody.tsx
@@ -0,0 +1,32 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3export type Props<Tag extends HTMLTag> = Polymorphic<{
4 "as"?: Tag;
5 "list"?: boolean;
6 "main"?: boolean;
7 "no-mbs-neutralize"?: boolean;
8 "children"?: JSX.Element;
9}>;
10
11export const CardBody = <T extends HTMLTag = "div">({
12 as: Tag = "div" as T,
13 list,
14 main,
15 "no-mbs-neutralize": noMbsNeutralize,
16 class: cls,
17 ...props
18}: Props<T>) => (
19 <Tag
20 class:list={[
21 "o-card__body",
22 {
23 "o-card__body--main": main,
24 "o-card__header": Tag === "summary",
25 "l-card-list__card-body": list,
26 "o-card__body--no-mbs-neutralize": noMbsNeutralize,
27 },
28 cls,
29 ]}
30 {...props}
31 />
32);
diff --git a/src/ui/objects/CardFooter.tsx b/src/ui/objects/CardFooter.tsx
new file mode 100644
index 0000000..63d74d0
--- /dev/null
+++ b/src/ui/objects/CardFooter.tsx
@@ -0,0 +1,11 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3export type Props<Tag extends HTMLTag> = Polymorphic<{
4 as?: Tag;
5}>;
6
7export const CardFooter = <T extends HTMLTag = "div">({
8 as: Tag = "div" as T,
9 class: cls,
10 ...props
11}: Props<T>) => <Tag class:list={["o-card__footer", cls]} {...props} />;
diff --git a/src/ui/objects/CardImage.tsx b/src/ui/objects/CardImage.tsx
new file mode 100644
index 0000000..12b9735
--- /dev/null
+++ b/src/ui/objects/CardImage.tsx
@@ -0,0 +1,40 @@
1import type { Props as ImageProps } from "corvos/plugins/images.ts";
2import type { CSSProperties } from "https://cdn.jsdelivr.net/gh/oscarotero/ssx@0.1.14/css.ts";
3
4import { Image } from "corvos/plugins/images.ts";
5
6export type Props = ImageProps & {
7 "list"?: boolean;
8 "img-class"?: unknown;
9 "img-style"?: CSSProperties;
10 "aspect"?: `${number} / ${number}`;
11 "content-class"?: unknown;
12 "children"?: JSX.Element;
13};
14
15export const CardImage = ({
16 children,
17 list,
18 "img-class": imgClass,
19 aspect,
20 "content-class": contentClass,
21 alt,
22 class: cls,
23 style,
24 "img-style": imgStyle,
25 ...props
26}: Props) => (
27 <div class:list={["o-card__image", cls]} style={style}>
28 <Image
29 class:list={["o-card__image-img", { "l-card-list__card-image": list }, imgClass]}
30 style={{
31 "aspect-ratio": aspect,
32 "block-size": aspect ? "auto" : undefined,
33 ...imgStyle,
34 }}
35 alt={alt}
36 {...props}
37 />
38 {children && <div class:list={["o-card__image-overlay", contentClass]}>{children}</div>}
39 </div>
40);
diff --git a/src/ui/objects/CollapseCard.tsx b/src/ui/objects/CollapseCard.tsx
new file mode 100644
index 0000000..27e1ce2
--- /dev/null
+++ b/src/ui/objects/CollapseCard.tsx
@@ -0,0 +1,27 @@
1import { Card, type Props as CardProps } from "./Card.tsx";
2import { CardBody } from "./CardBody.tsx";
3import { Heading } from "./Heading.tsx";
4
5export type Props = CardProps<"details"> & {
6 "heading-level": "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
7 "title"?: JSX.Element;
8 "header"?: JSX.Element;
9};
10
11export const CollapseCard = ({
12 "heading-level": headingLevel,
13 children,
14 title,
15 header,
16 ...props
17}: Props) => (
18 <Card as="details" {...props}>
19 <CardBody as="summary">
20 <Heading as={headingLevel} class="u-mbs-0" level="lg">
21 {title}
22 </Heading>
23 {header}
24 </CardBody>
25 <CardBody>{children}</CardBody>
26 </Card>
27);
diff --git a/src/ui/objects/ContentImage.tsx b/src/ui/objects/ContentImage.tsx
new file mode 100644
index 0000000..ef45f89
--- /dev/null
+++ b/src/ui/objects/ContentImage.tsx
@@ -0,0 +1,15 @@
1import type { ClassValue } from "corvos/deps/clsx.ts";
2import type { Props as ImageProps } from "corvos/plugins/images.ts";
3
4import { Image } from "corvos/plugins/images.ts";
5
6export type Props = Omit<ImageProps, "src"> & {
7 "src"?: string;
8 "img-class"?: ClassValue;
9};
10
11export const ContentImage = ({ src, "img-class": imgClass, ...props }: Props) => (
12 <a href={src} data-lightbox {...props}>
13 <Image src={src} width="1400" class:list={imgClass} />
14 </a>
15);
diff --git a/src/ui/objects/Deflist.tsx b/src/ui/objects/Deflist.tsx
new file mode 100644
index 0000000..53d60d6
--- /dev/null
+++ b/src/ui/objects/Deflist.tsx
@@ -0,0 +1,7 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3export type Props = HTMLAttributes<"dl">;
4
5export const Deflist = ({ class: cls, ...props }: Props) => (
6 <dl class={`o-deflist ${cls ?? ""}`} {...props} />
7);
diff --git a/src/ui/objects/Dialog.tsx b/src/ui/objects/Dialog.tsx
new file mode 100644
index 0000000..8ab6c4f
--- /dev/null
+++ b/src/ui/objects/Dialog.tsx
@@ -0,0 +1,41 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3import { Icon } from "$/ui/objects/Icon.tsx";
4
5import { Container } from "../layouts/Container.tsx";
6import { ActionButton } from "./ActionButton.tsx";
7import { Divider } from "./Divider.tsx";
8import { Heading } from "./Heading.tsx";
9
10export type Props = HTMLAttributes<"dialog"> & {
11 title: string;
12 divider?: boolean;
13 children?: JSX.Element;
14};
15
16export const Dialog = ({ children, title, divider, class: cls, ...props }: Props) => (
17 <dialog popover="auto" class:list={["o-dialog is-modal", cls]} closedby="any" {...props}>
18 <Container fixed class="o-dialog__content">
19 <header class="o-dialog__header">
20 <Heading as="h1" class="u-mbs-0" level="xxl">
21 {title}
22 </Heading>
23
24 <ActionButton
25 command="close"
26 commandfor={props.id}
27 as="button"
28 pill
29 size="lg"
30 level="quiet"
31 icon={<Icon icon="x" variant="bold" />}
32 class="u-mis-auto"
33 />
34 </header>
35
36 {divider && <Divider level="faint" class="o-dialog__divider" />}
37
38 <div class="o-dialog__body">{children}</div>
39 </Container>
40 </dialog>
41);
diff --git a/src/ui/objects/Divider.tsx b/src/ui/objects/Divider.tsx
new file mode 100644
index 0000000..8f7efcd
--- /dev/null
+++ b/src/ui/objects/Divider.tsx
@@ -0,0 +1,51 @@
1import type { HTMLAttributes, HTMLTag } from "corvos/plugins/jsx.ts";
2
3export type Props = HTMLAttributes<"div" | "hr"> & {
4 "level"?: "strong" | "medium" | "quiet" | "faint";
5 "variant"?: "static-black" | "static-white";
6 "vertical"?: boolean | "dot";
7 "label-as"?: HTMLTag;
8 "children"?: JSX.Element;
9};
10
11export const Divider = ({
12 children,
13 level = "strong",
14 variant,
15 vertical,
16 "label-as": Label = "span",
17 class: cls,
18 ...props
19}: Props) =>
20 children ? (
21 <div
22 class:list={[
23 "o-divider",
24 "o-divider--labelled",
25 `o-divider--${level}`,
26 {
27 [`o-divider--${variant}`]: variant,
28 "o-divider--vertical": vertical === true,
29 "o-divider--dot": vertical === "dot",
30 },
31 cls,
32 ]}
33 {...props}
34 >
35 <Label class="o-divider__label u-mbs-0">{children}</Label>
36 </div>
37 ) : (
38 <hr
39 class:list={[
40 "o-divider",
41 `o-divider--${level}`,
42 {
43 [`o-divider--${variant}`]: variant,
44 "o-divider--vertical": vertical === true,
45 "o-divider--dot": vertical === "dot",
46 },
47 cls,
48 ]}
49 {...props}
50 />
51 );
diff --git a/src/ui/objects/Emoji.tsx b/src/ui/objects/Emoji.tsx
new file mode 100644
index 0000000..f04f776
--- /dev/null
+++ b/src/ui/objects/Emoji.tsx
@@ -0,0 +1,21 @@
1import site from "@/corvos.config.ts";
2
3export interface Props {
4 pack: string;
5 emoji: string;
6 size?: "125" | "150" | "200";
7 class?: string;
8}
9
10export const Emoji = site.$.component(
11 ({ pack, emoji, size, class: cls, ...props }: Props, pages) => {
12 const query = site.$.query(pages);
13 const sprite = query.type(".svg").match({ emojiPack: pack }).one();
14
15 return (
16 <svg class:list={["o-emoji o-emoji--sprite", { [`o-emoji--${size}`]: size }, cls]} {...props}>
17 <use href={`${sprite.url}#${emoji}`} />
18 </svg>
19 );
20 },
21);
diff --git a/src/ui/objects/Figure.tsx b/src/ui/objects/Figure.tsx
new file mode 100644
index 0000000..7f5e20a
--- /dev/null
+++ b/src/ui/objects/Figure.tsx
@@ -0,0 +1,13 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3export type Props = HTMLAttributes<"figure"> & {
4 children?: JSX.Element;
5 caption?: JSX.Element;
6};
7
8export const Figure = ({ children, caption, class: cls, ...props }: Props) => (
9 <figure class:list={["o-figure", cls]} {...props}>
10 {children}
11 {caption && <figcaption class="o-figure__caption">{caption}</figcaption>}
12 </figure>
13);
diff --git a/src/ui/objects/FigureCaption.tsx b/src/ui/objects/FigureCaption.tsx
new file mode 100644
index 0000000..4112170
--- /dev/null
+++ b/src/ui/objects/FigureCaption.tsx
@@ -0,0 +1,11 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3export type Props = HTMLAttributes<"figcaption"> & {
4 children?: JSX.Element;
5};
6
7export const FigureCaption = ({ children, class: cls, ...props }: Props) => (
8 <figcaption class:list={["o-figure__caption", cls]} {...props}>
9 {children}
10 </figcaption>
11);
diff --git a/src/ui/objects/Heading.tsx b/src/ui/objects/Heading.tsx
new file mode 100644
index 0000000..089de3a
--- /dev/null
+++ b/src/ui/objects/Heading.tsx
@@ -0,0 +1,49 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3export type Props<Tag extends HTMLTag> = Polymorphic<{
4 as: Tag;
5 level?: "xxl" | "xl" | "lg" | "md" | "sm" | "xs";
6 variant?: "static-black" | "static-white";
7 display?: boolean;
8 highlight?: boolean;
9 children?: JSX.Element;
10}>;
11
12const levelTagMap = {
13 lg: "h3",
14 md: "h4",
15 sm: "h5",
16 xl: "h2",
17 xs: "h6",
18 xxl: "h1",
19} as const;
20
21export const Heading = <T extends HTMLTag = "div">({
22 as,
23 children,
24 display,
25 level,
26 variant,
27 highlight,
28 class: cls,
29 ...props
30}: Props<T>) => {
31 const Tag = as ?? (level ? levelTagMap[level] : "strong");
32 return (
33 <Tag
34 class:list={[
35 "o-heading",
36 {
37 [`o-heading--${level}`]: level,
38 [`o-heading--${variant}`]: variant,
39 "o-heading--display": display,
40 "u-c-heading": Tag === "strong",
41 },
42 cls,
43 ]}
44 {...props}
45 >
46 {highlight ? <span class="o-heading__highlight">{children}</span> : children}
47 </Tag>
48 );
49};
diff --git a/src/ui/objects/Icon.tsx b/src/ui/objects/Icon.tsx
new file mode 100644
index 0000000..e8af718
--- /dev/null
+++ b/src/ui/objects/Icon.tsx
@@ -0,0 +1,20 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3import site from "@/corvos.config.ts";
4
5export type Props = HTMLAttributes<"svg"> & {
6 icon: Parameters<typeof site.$.icon>[0];
7 variant?: Parameters<typeof site.$.icon>[1];
8};
9
10export const Icon = ({ icon, variant, class: cls, ...props }: Props) => (
11 <svg
12 aria-hidden="true"
13 class:list={["o-icon", cls]}
14 height="1.142857143em"
15 width="1.142857143em"
16 {...props}
17 >
18 <use href={site.$.icon(icon, variant)} />
19 </svg>
20);
diff --git a/src/ui/objects/Lightbox.tsx b/src/ui/objects/Lightbox.tsx
new file mode 100644
index 0000000..00268cc
--- /dev/null
+++ b/src/ui/objects/Lightbox.tsx
@@ -0,0 +1,122 @@
1/** Biome-ignore-all lint/a11y/useValidAnchor: <explanation> */
2/** Biome-ignore-all lint/a11y/useAltText: <explanation> */
3
4import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
5
6import { Icon } from "$/ui/objects/Icon.tsx";
7
8import { ActionButton } from "./ActionButton.tsx";
9import { LoadingIndicator } from "./LoadingIndicator.tsx";
10import { Thumbnail } from "./Thumbnail.tsx";
11
12export type Props = HTMLAttributes<"dialog">;
13
14export const Lightbox = ({ class: cls, ...props }: Props) => (
15 <dialog
16 class:list={["o-lightbox o-lightbox--static-black is-modal t-static-white", cls]}
17 closedby="any"
18 x-id="['lightbox']"
19 x-bind:id="$id('lightbox')"
20 x-data="lightbox"
21 x-on:close="handleClose"
22 x-on:touchstart="handleTouchStart"
23 x-on:touchend="handleTouchEnd"
24 {...{
25 "x-on:keydown.document.capture": "handleKeyDown",
26 }}
27 {...props}
28 >
29 <div x-show="items.length > 1" class="o-lightbox__info">
30 <small class="u-mis-100 u-mie-200 u-d-none@sm-lo">
31 <strong x-text="current + 1"></strong> <span class="u-c-mute-more u-mi-50">&#x2044;</span>{" "}
32 <strong x-text="items.length"></strong>
33 </small>
34 <ActionButton
35 x-on:click="prev"
36 as="button"
37 pill
38 level="quiet"
39 icon={<Icon icon="caret-left" variant="bold" />}
40 variant="static-white"
41 />
42 <ActionButton
43 x-on:click="next"
44 as="button"
45 pill
46 level="quiet"
47 icon={<Icon icon="caret-right" variant="bold" />}
48 variant="static-white"
49 />
50 </div>
51
52 <div class="o-lightbox__controls">
53 <ActionButton
54 x-show="zoomedFull"
55 x-on:click="toggleZoom"
56 as="button"
57 class="u-mie-100"
58 pill
59 level="quiet"
60 icon={<Icon icon="magnifying-glass-minus" variant="bold" />}
61 variant="static-white"
62 />
63 <ActionButton
64 x-show="!zoomedFull"
65 x-on:click="toggleZoom"
66 as="button"
67 class="u-mie-100"
68 pill
69 level="quiet"
70 icon={<Icon icon="magnifying-glass-plus" variant="bold" />}
71 variant="static-white"
72 />
73 <ActionButton
74 x-bind:commandfor="$id('lightbox')"
75 command="close"
76 as="button"
77 pill
78 level="quiet"
79 icon={<Icon icon="x" variant="bold" />}
80 variant="static-white"
81 />
82 </div>
83
84 <img
85 class="o-lightbox__img"
86 x-ref="img"
87 x-bind:src="currentItem?.src ?? ''"
88 x-bind:srcset="currentItem?.srcset ?? ''"
89 x-bind:alt="currentItem?.alt ?? ''"
90 x-on:load="loading = false"
91 x-on:mousemove="handleMouseMove"
92 x-on:touchmove="handleTouchMove"
93 x-on:dblclick="handleDoubleClick"
94 x-bind:class="{ 'is-hidden': loading }"
95 x-bind:style="{ transform: `translate(${offsetX}px, ${offsetY}px) scale(${zoom})` }"
96 {...{
97 "x-on:mousedown.prevent": "handleMouseDown",
98 "x-on:mouseup.window": "handleMouseUp",
99 "x-on:wheel.prevent": "handleScroll",
100 }}
101 />
102
103 <div class="o-lightbox__placeholder">
104 <LoadingIndicator />
105 </div>
106
107 <div class="o-lightbox__thumbnails" x-show="items.length > 1" x-ref="thumbnails">
108 <template x-for="(item, i) in items">
109 <Thumbnail
110 class="o-lightbox__thumbnail"
111 variant="static-white"
112 x-bind:src="item.thumbnail ?? item.src"
113 x-bind:srcset="item.srcset"
114 x-bind:alt="item.alt"
115 x-bind:href="item.src"
116 x-bind:class="{ 'is-selected': i === current }"
117 {...{ "x-on:click.prevent": "current = i" }}
118 />
119 </template>
120 </div>
121 </dialog>
122);
diff --git a/src/ui/objects/LoadingIndicator.tsx b/src/ui/objects/LoadingIndicator.tsx
new file mode 100644
index 0000000..325cbb3
--- /dev/null
+++ b/src/ui/objects/LoadingIndicator.tsx
@@ -0,0 +1,10 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3export type Props = HTMLAttributes<"svg">;
4
5export const LoadingIndicator = (props: Props) => (
6 <svg height="50" width="50" {...props}>
7 <title>Loading indicator</title>
8 <use href="#loading-indicator" />
9 </svg>
10);
diff --git a/src/ui/objects/Navbar.tsx b/src/ui/objects/Navbar.tsx
new file mode 100644
index 0000000..4439e58
--- /dev/null
+++ b/src/ui/objects/Navbar.tsx
@@ -0,0 +1,37 @@
1/** Biome-ignore-all lint/a11y/noNoninteractiveElementToInteractiveRole: <explanation> */
2
3import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
4
5export type Props = HTMLAttributes<"nav"> & {
6 "adapt"?: boolean;
7 "quiet"?: boolean;
8 "align-block"?: boolean;
9 "variant"?: "static-black" | "static-white";
10};
11
12export const Navbar = ({
13 adapt,
14 quiet,
15 "align-block": alignBlock,
16 variant,
17 class: cls,
18 ...props
19}: Props) => (
20 <nav
21 class:list={[
22 "o-navbar",
23 {
24 "o-navbar--adapt": adapt,
25 "o-navbar--quiet": quiet,
26 "o-navbar--align-block": alignBlock,
27 [`o-navbar--${variant}`]: variant,
28 },
29 "js-keyboard-nav",
30 cls,
31 ]}
32 data-keyboard-nav-item=".o-navbar__item"
33 data-keyboard-nav-mode="nav"
34 role="menubar"
35 {...props}
36 />
37);
diff --git a/src/ui/objects/NavbarItem.tsx b/src/ui/objects/NavbarItem.tsx
new file mode 100644
index 0000000..c0e347f
--- /dev/null
+++ b/src/ui/objects/NavbarItem.tsx
@@ -0,0 +1,33 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3export type Props<Tag extends HTMLTag> = Polymorphic<{
4 as: Tag;
5 icon?: JSX.Element;
6 pre?: JSX.Element;
7 post?: JSX.Element;
8 selected?: boolean;
9}>;
10
11export const NavbarItem = <T extends HTMLTag = "a">({
12 children,
13 as: Tag = "a" as T,
14 icon,
15 pre,
16 post,
17 selected,
18 class: cls,
19 ...props
20}: Props<T>) => (
21 <Tag
22 aria-current={selected ? "page" : undefined}
23 class:list={["o-navbar__item", { "is-selected": selected }, cls]}
24 role="menuitem"
25 {...props}
26 >
27 {icon && <div class="o-navbar__item-label">{children}</div>}
28
29 <div class="o-navbar__item-content">
30 {pre} {icon ?? <span class="o-navbar__item-content-text">{children}</span>} {post}
31 </div>
32 </Tag>
33);
diff --git a/src/ui/objects/PlaceholderBox.tsx b/src/ui/objects/PlaceholderBox.tsx
new file mode 100644
index 0000000..d64866f
--- /dev/null
+++ b/src/ui/objects/PlaceholderBox.tsx
@@ -0,0 +1,11 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3export type Props<Tag extends HTMLTag> = Polymorphic<{
4 as: Tag;
5}>;
6
7export const PlaceholderBox = <T extends HTMLTag = "div">({
8 as: Tag = "div" as T,
9 class: cls,
10 ...props
11}: Props<T>) => <Tag class:list={["o-placeholder-box u-ta-center u-c-mute", cls]} {...props} />;
diff --git a/src/ui/objects/SideNav.tsx b/src/ui/objects/SideNav.tsx
new file mode 100644
index 0000000..f96ce7a
--- /dev/null
+++ b/src/ui/objects/SideNav.tsx
@@ -0,0 +1,14 @@
1/** Biome-ignore-all lint/a11y/noNoninteractiveElementToInteractiveRole: <explanation> */
2
3import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
4
5export type Props = HTMLAttributes<"nav">;
6
7export const SideNav = ({ class: cls, ...props }: Props) => (
8 <nav
9 class:list={["o-side-nav", "js-keyboard-nav", cls]}
10 data-keyboard-nav-item=".o-side-nav__item"
11 data-keyboard-nav-mode="list"
12 {...props}
13 />
14);
diff --git a/src/ui/objects/SideNavItem.tsx b/src/ui/objects/SideNavItem.tsx
new file mode 100644
index 0000000..fb8f523
--- /dev/null
+++ b/src/ui/objects/SideNavItem.tsx
@@ -0,0 +1,33 @@
1import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts";
2
3export type Props<Tag extends HTMLTag> = Polymorphic<{
4 as: Tag;
5 icon?: JSX.Element;
6 pre?: JSX.Element;
7 post?: JSX.Element;
8 selected?: boolean;
9}>;
10
11export const SideNavItem = <T extends HTMLTag = "a">({
12 children,
13 as: Tag = "a" as T,
14 icon,
15 pre,
16 post,
17 selected,
18 class: cls,
19 ...props
20}: Props<T>) => (
21 <Tag
22 aria-current={selected ? "location" : undefined}
23 class:list={["o-side-nav__item", { "is-selected": selected }, cls]}
24 role="menuitem"
25 {...props}
26 >
27 {icon && <div class="l-media__block o-side-nav__icon-slot">{icon}</div>}
28
29 <div class="l-media__block l-media__block--main">
30 {pre} {children} {post}
31 </div>
32 </Tag>
33);
diff --git a/src/ui/objects/StatusIndicator.tsx b/src/ui/objects/StatusIndicator.tsx
new file mode 100644
index 0000000..06b6481
--- /dev/null
+++ b/src/ui/objects/StatusIndicator.tsx
@@ -0,0 +1,18 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3export type Props = HTMLAttributes<"div"> & {
4 status?: "accent" | "positive" | "negative" | "warning";
5};
6
7export const StatusIndicator = ({ status, class: cls, ...props }: Props) => (
8 <div
9 class:list={[
10 "o-status-indicator",
11 {
12 [`is-${status}`]: status,
13 },
14 cls,
15 ]}
16 {...props}
17 />
18);
diff --git a/src/ui/objects/Tab.tsx b/src/ui/objects/Tab.tsx
new file mode 100644
index 0000000..15c1e5d
--- /dev/null
+++ b/src/ui/objects/Tab.tsx
@@ -0,0 +1,14 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3export type Props = HTMLAttributes<"a"> & {
4 selected?: boolean;
5};
6
7export const Tab = ({ selected, class: cls, ...props }: Props) => (
8 <a
9 aria-selected={`${selected}`}
10 class:list={["o-tabbar__tab", { "is-selected": selected }, cls]}
11 role="tab"
12 {...props}
13 />
14);
diff --git a/src/ui/objects/Tabbar.tsx b/src/ui/objects/Tabbar.tsx
new file mode 100644
index 0000000..58c58fa
--- /dev/null
+++ b/src/ui/objects/Tabbar.tsx
@@ -0,0 +1,43 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3export type Props = HTMLAttributes<"nav"> & {
4 "variant"?: "accent";
5 "quiet"?: boolean;
6 "adapt"?: boolean;
7 "tabs-class"?: unknown;
8};
9
10export const Tabbar = ({
11 children,
12 variant,
13 quiet,
14 adapt,
15 "tabs-class": tabsClass,
16 class: cls,
17 ...props
18}: Props) => (
19 <nav
20 class:list={[
21 "o-tabbar",
22 {
23 [`o-tabbar--${variant}`]: variant,
24 "o-tabbar--quiet": quiet,
25 "o-tabbar--adapt": adapt,
26 },
27 "js-keyboard-nav",
28 cls,
29 ]}
30 data-keyboard-nav-item=".o-tabbar__tab"
31 data-keyboard-nav-mode="nav"
32 {...props}
33 >
34 <div
35 aria-label={props["aria-label"]}
36 aria-orientation="horizontal"
37 class:list={["o-tabbar__tabs", tabsClass]}
38 role="tablist"
39 >
40 {children}
41 </div>
42 </nav>
43);
diff --git a/src/ui/objects/TextField.tsx b/src/ui/objects/TextField.tsx
new file mode 100644
index 0000000..dc7893a
--- /dev/null
+++ b/src/ui/objects/TextField.tsx
@@ -0,0 +1,62 @@
1import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
2
3export type Props = HTMLAttributes<"input"> & {
4 "pre"?: JSX.Element;
5 "post"?: JSX.Element;
6 "invalid"?: boolean;
7 "pill"?: boolean;
8 "x-model"?: string;
9};
10
11export const TextField = ({
12 pre,
13 post,
14 disabled,
15 invalid,
16 pill,
17 placeholder,
18 readonly,
19 value,
20 pattern,
21 required,
22 "x-model": xModel,
23 class: cls,
24 ...props
25}: Props) => {
26 const extended = !!pre || !!post;
27
28 return (
29 <div
30 class:list={[
31 "o-text-field",
32 {
33 "is-disabled": disabled,
34 "is-invalid": invalid,
35 "l-media": extended,
36 "l-media--gapless": extended,
37 "o-text-field--extended": extended,
38 "o-text-field--pill": pill,
39 },
40 cls,
41 ]}
42 {...props}
43 >
44 {pre}{" "}
45 <input
46 class="o-text-field__native"
47 class:list={{
48 "l-media__block": extended,
49 "l-media__block--main": extended,
50 }}
51 disabled={disabled}
52 pattern={pattern}
53 placeholder={placeholder}
54 readonly={readonly}
55 required={required}
56 value={value}
57 x-model={xModel}
58 />{" "}
59 {post} <div class="o-text-field__bg"></div>
60 </div>
61 );
62};
diff --git a/src/ui/objects/Thumbnail.tsx b/src/ui/objects/Thumbnail.tsx
new file mode 100644
index 0000000..4ef76d3
--- /dev/null
+++ b/src/ui/objects/Thumbnail.tsx
@@ -0,0 +1,46 @@
1import type { Props as ImageProps } from "corvos/plugins/images.ts";
2import type { HTMLAttributes } from "corvos/plugins/jsx.ts";
3
4import { Image } from "corvos/plugins/images.ts";
5
6export type Props = HTMLAttributes<"a"> &
7 ImageProps & {
8 "x-bind:src"?: string;
9 "x-bind:srcset"?: string;
10 "x-bind:alt"?: string;
11 "variant"?: "static-black" | "static-white";
12 };
13
14export const Thumbnail = ({
15 src,
16 srcset,
17 alt,
18 "x-bind:src": xbindSrc,
19 "x-bind:srcset": xbindSrcset,
20 "x-bind:alt": xbindAlt,
21 variant,
22 class: cls,
23 widths,
24 width,
25 height,
26 densities,
27 "no-transform": noTransform,
28 ...props
29}: Props) => (
30 <a class:list={["o-thumbnail", { [`o-thumbnail--${variant}`]: variant }, cls]} {...props}>
31 <Image
32 class="o-thumbnail__image"
33 src={src}
34 srcset={srcset}
35 widths={widths as undefined}
36 width={width}
37 height={height}
38 densities={densities}
39 no-transform={noTransform}
40 alt={alt}
41 x-bind:src={xbindSrc}
42 x-bind:srcset={xbindSrcset}
43 x-bind:alt={xbindAlt}
44 />
45 </a>
46);