diff --git a/.agents/docs/astro-v6-refactor-research.md b/.agents/docs/astro-v6-refactor-research.md new file mode 100644 index 00000000..4f46cd3b --- /dev/null +++ b/.agents/docs/astro-v6-refactor-research.md @@ -0,0 +1,210 @@ +# Astro v6 refactor research notes + +## Scope + +This repo is a pnpm workspace with: + +- a root Astro docs/demo site +- four publishable packages under `packages/` +- existing Node version pinning via `.nvmrc` + +The root site was still on Astro 4 + Tailwind 3 and relied on several old patterns: + +- removed resource helpers like `Astro.resolve()` +- old markdown rendering flow on the homepage +- direct filesystem globbing in `.astro` components +- font loading via raw `` tags +- custom icon fonts instead of SVG icon tooling + +## Current repo findings + +### Root app + +- `package.json` + - Astro `^4.10.0` + - `@astrojs/check` `^0.7.0` + - Tailwind `3.4.4` + - no adapter + - no icon integration + - no mise config +- `astro.config.ts` + - empty +- `src/pages` + - `index.astro` used `Astro.fetchContent('../../README.md')` + - `demo/index.astro` used old static script path conventions +- `src/layouts/BaseLayout.astro` + - used `Astro.resolve()` for styles + - loaded Google Fonts manually +- `src/components/Sidebar.astro` + - used `fast-glob('docs/**/*.md')` + - depended on a font icon glyph for chevrons +- `src/content` + - contains the actual documentation corpus and should be treated as the source of truth + +### Workspace/tooling + +- `.nvmrc` pins Node `22` +- `pnpm-workspace.yaml` includes `packages/**` +- no `mise.toml` or `.mise.toml` existed + +### Validation baseline + +I attempted a full `corepack pnpm install` before refactoring. + +Observed blocker: + +- workspace install failed on `packages/animate` while resolving `npm:@jsr/dynimorius__color-utilities@^1.0.11` +- the environment could not resolve `npm.jsr.io` + +This is a repo-level validation issue unrelated to the Astro migration itself, but it affects the ability to do a full clean install in this sandbox. + +## Astro v6 research summary + +### Astro core + +Recommended migration targets for this repo: + +- upgrade to Astro 6.x +- keep the site on modern ESM-only patterns +- replace removed APIs with standard imports and Vite URLs +- move markdown docs to a single dynamic route instead of ad-hoc rendering + +Practical implications here: + +- import global styles directly in layout frontmatter +- use `?url` for client script assets +- build docs pages from `import.meta.glob('/src/content/**/*.md')` + +### Astro auto adapter + +Best fit for this repo: + +- use `astro-auto-adapter` in config +- keep adapter selection environment-driven +- default to server output so the adapter is active and portable +- use Node standalone mode as the safe default fallback + +Reasoning: + +- the user explicitly requested `astro-auto-adapter` +- deployment target is not pinned in this repo +- auto adapter keeps the site portable while still allowing later hard-pinning if production hosting becomes fixed + +### Astro fonts API + +Best fit for this repo: + +- move typography to the Astro fonts config +- define CSS variables in config and consume them from Tailwind/theme CSS +- self-host through Astro’s providers instead of raw `` tags + +Recommended fonts for this refactor: + +- Lexend for body/UI text +- Geist Mono for code and demos + +Benefits: + +- cleaner head markup +- centralized font management +- predictable CSS variable integration + +### Icons: `astro-icon` + `unplugin-icons` + +Recommended split: + +- `astro-icon` for content/UI icons rendered by Astro components +- `unplugin-icons` for direct component imports where a Vite-generated Astro component is convenient + +Applied rationale in this repo: + +- `astro-icon` is a good replacement for the hand-written playback SVG switch +- `unplugin-icons` is a good replacement for the old sidebar chevron icon font + +### Tailwind v4 + +Best practices for this repo: + +- use `@tailwindcss/vite` +- switch to CSS-first configuration +- define custom colors and fonts through `@theme` +- remove reliance on old Tailwind entry directives and old config assumptions + +Important migration note: + +- this repo already has a lot of SCSS; the least risky path is not a full styling rewrite +- instead, keep SCSS where it provides value, but let Tailwind v4 generate utilities and theme tokens + +### Markdown/docs architecture + +Recommended structure: + +- keep markdown files in `src/content` +- create one catch-all docs route under `src/pages/docs/[...slug].astro` +- generate both: + - pretty URLs like `/docs/animate/` + - legacy URLs like `/docs/animate/index.md` + +Why this matters: + +- existing markdown content already links to many `.md` paths +- dual-route generation preserves those links during migration + +## Dependency research and decisions + +### New/updated root site dependencies + +- `astro` → `^6.1.9` +- `@astrojs/check` → `^0.9.8` +- `tailwindcss` → `^4.2.4` +- `@tailwindcss/vite` → `^4.2.4` +- `astro-auto-adapter` → `^2.5.5` +- `astro-icon` → `^1.1.5` +- `unplugin-icons` → `^23.0.1` +- `@iconify-json/fluent` → `^1.2.45` +- `typescript` → `^6.0.2` + +Security check: + +- GitHub advisory database returned no known vulnerabilities for the newly added npm packages above + +## Mise research + +Recommended usage for this repo: + +- keep version pinning at the workspace root +- align mise with the existing Node policy from `.nvmrc` +- pin pnpm explicitly because the repo already depends on pnpm workspace behavior + +Minimal config: + +```toml +[tools] +node = "22" +pnpm = "9.2.0" +``` + +Why add mise here: + +- it complements `.nvmrc` instead of replacing it +- it makes the repo easier to bootstrap in CI/devcontainers/Codespaces +- it keeps Node + pnpm aligned for Astro, Vite, and workspace scripts + +## Refactor direction chosen for this repo + +1. Upgrade the root docs site to Astro 6. +2. Add `astro-auto-adapter`, Astro fonts config, Tailwind v4, `astro-icon`, and `unplugin-icons`. +3. Replace removed Astro patterns with import-based equivalents. +4. Build docs pages from the markdown corpus in `src/content`. +5. Preserve legacy `.md` documentation links while also exposing clean URLs. +6. Add mise config at the repo root. + +## Remaining follow-up items after the core refactor + +- resolve the `packages/animate` JSR install blocker cleanly for full workspace installs +- decide whether production should remain on auto-adapter or be pinned to one official deployment adapter +- optionally add richer docs features later: + - content collections schema + - syntax highlighting upgrades + - generated table of contents + - search diff --git a/.agents/docs/review-followup-research.md b/.agents/docs/review-followup-research.md new file mode 100644 index 00000000..bceb7847 --- /dev/null +++ b/.agents/docs/review-followup-research.md @@ -0,0 +1,79 @@ +# Review follow-up research notes + +## Astro 6 content collections follow-up + +### Why switch from `import.meta.glob` + +The previous docs route used `import.meta.glob()` and then rebuilt routing metadata manually. + +Astro content collections are a better fit because they provide: + +- typed collection entries +- schema validation for frontmatter +- `getCollection()` and `render()` helpers +- better alignment with Astro 6 content tooling + +### Migration approach for this repo + +- Keep the markdown files in `src/content/` because no other active tooling in this repo currently consumes them directly. +- Define a single `docs` collection with the Astro 6 `glob()` loader from `astro/loaders`. +- Preserve the original relative file path as the content entry ID so we can continue generating both: + - clean URLs like `/docs/native/` + - legacy URLs like `/docs/native/index.md` + +### Astro 6 additions relevant here + +- Astro 6 continues the content-layer/content-loader direction and makes content collections the preferred typed content API. +- The `render(entry)` API gives a properly typed `Content` component, which removes the need for loose `import.meta.glob()` module casting in the route. + +## Mobile sidebar/menu research + +### Requested UI behavior + +- add a mobile menu button +- place it at the bottom-right corner +- use `astro-icon` +- use a Hugeicons menu icon + +### Implementation notes + +- `astro-icon` can render Iconify-backed Hugeicons entries directly +- the Iconify package for Hugeicons is `@iconify-json/hugeicons` +- a valid menu icon name is `hugeicons:menu-01` + +### Best-practice behavior + +- use a real ` + + - \ No newline at end of file + + + diff --git a/src/components/SidebarSection.astro b/src/components/SidebarSection.astro index 36a84eaf..3a8552f5 100644 --- a/src/components/SidebarSection.astro +++ b/src/components/SidebarSection.astro @@ -1,27 +1,25 @@ --- -let { props } = Astro; -let { item, details, content } = props; +import ChevronRight from "~icons/fluent/chevron-right-20-filled"; +import type { DocTreeNode } from "../lib/docs"; -function isObject(obj) { - return typeof obj === 'object' && obj !== null && obj !== undefined +export interface Props { + node: DocTreeNode; } -// Capitalize first letter of each word -let capitalize = (str) => { - return str.replace(/-/g, " ").replace(/\w\S*/g, function(txt){ - return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); - }).replace(/api/gi, "API").replace(/css/gi, "CSS"); -}; +const { node } = Astro.props; --- -{ - isObject(details) ? - ( -
- -

{["animate", "emitter", "manager", "native"].includes(item) ? `@okikio/${item}` : capitalize(item)}

-
-
{content}
-
- ) : ({item == "index" ? "Overview" : capitalize(item)}) -} \ No newline at end of file +{node.kind === "group" ? ( +
+ + + +
+ {node.children?.map((child) => )} +
+
+) : ( + {node.label} +)} diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 00000000..6ef5e739 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,17 @@ +import { defineCollection } from "astro:content"; +import { glob } from "astro/loaders"; +import { z } from "astro/zod"; + +const docs = defineCollection({ + loader: glob({ + base: "./src/content", + pattern: "**/*.md", + generateId: ({ entry }) => entry.replaceAll("\\", "/"), + }), + schema: z.object({ + title: z.string().optional(), + description: z.string().optional(), + }), +}); + +export const collections = { docs }; diff --git a/src/env.d.ts b/src/env.d.ts index f964fe0c..317b535b 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1 +1,2 @@ /// +/// diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index aca267f2..be95bbf0 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,49 +1,33 @@ --- import Sidebar from "../components/Sidebar.astro"; ---- - - - - - - { Astro.props?.title ?? "Homepage" } - - - - - - - - - - - - - - - +import "../style/global.css"; - - - +export interface Props { + title?: string; + description?: string; +} - - - - -
- - -
- -
-
- - +const { title = "native", description = "Documentation and demos for the native initiative." } = Astro.props; +--- - - - - - \ No newline at end of file + + + + + + + + {title} + + + + +
+ + +
+ +
+
+ + diff --git a/src/layouts/PagesLayout.astro b/src/layouts/PagesLayout.astro index 9956708f..10d8954c 100644 --- a/src/layouts/PagesLayout.astro +++ b/src/layouts/PagesLayout.astro @@ -1,25 +1,33 @@ --- -// import { Markdown } from "astro/components"; import BaseLayout from "../layouts/BaseLayout.astro"; -const {content} = Astro.props; -let pathArr = Astro.request.url.pathname.split("/").filter(item => item && item.length); -let title = content?.match?.(/\#{1,6}(.*)/)?.[1]?.trim() ?? "Homepage"; -let pkg = "@okikio/" + pathArr[0]; +export interface Props { + title: string; + description?: string; + breadcrumbs?: Array<{ + label: string; + href: string; + }>; +} + +const { title, description, breadcrumbs = [] } = Astro.props; +const pageTitle = `${title} · native`; --- - -
-

- Home{ pathArr.length ? " / " : "" } - {pathArr.map((path, i) => { - return `${path}` - }).join(" / ")} -

+ +
+ -
- +
-
+
-
\ No newline at end of file + diff --git a/src/lib/docs.ts b/src/lib/docs.ts new file mode 100644 index 00000000..ac638d16 --- /dev/null +++ b/src/lib/docs.ts @@ -0,0 +1,206 @@ +import { getCollection, type CollectionEntry } from "astro:content"; + +export type DocsCollectionEntry = CollectionEntry<"docs">; + +export interface DocEntry { + entry: DocsCollectionEntry; + id: string; + prettySegments: string[]; + legacySegments: string[]; + href: string; + legacyHref: string; + label: string; +} + +export interface DocTreeNode { + kind: "group" | "page"; + key: string; + label: string; + href?: string; + children?: DocTreeNode[]; +} + +interface TreeGroup { + kind: "group"; + key: string; + label: string; + children: Map; +} + +interface TreePage { + kind: "page"; + key: string; + label: string; + href: string; +} + +type TreeNode = TreeGroup | TreePage; + +function normalizeId(id: string) { + return id.replaceAll("\\", "/"); +} + +function capitalize(segment: string) { + return segment + .replace(/-/g, " ") + .replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()) + .replace(/\bapi\b/gi, "API") + .replace(/\bcss\b/gi, "CSS"); +} + +function labelForSegment(segment: string, depth: number) { + if (depth === 0 && ["animate", "emitter", "manager", "native"].includes(segment)) { + return `@okikio/${segment}`; + } + + return capitalize(segment); +} + +function toPrettySegments(id: string) { + return normalizeId(id).replace(/\.md$/, "").replace(/\/index$/, "").split("/").filter(Boolean); +} + +function toLegacySegments(id: string) { + return normalizeId(id).split("/").filter(Boolean); +} + +function toHref(segments: string[], legacy = false) { + if (!segments.length) { + return "/docs/"; + } + + return `/docs/${segments.join("/")}${legacy ? "" : "/"}`; +} + +async function getDocsCollection() { + const entries = await getCollection("docs"); + return entries.sort((left, right) => left.id.localeCompare(right.id)); +} + +export async function getDocEntries(): Promise { + const entries = await getDocsCollection(); + + return entries.map((entry) => { + const id = normalizeId(entry.id); + const prettySegments = toPrettySegments(id); + const legacySegments = toLegacySegments(id); + const isOverview = /\/index\.md$/.test(id); + const legacyLeaf = legacySegments.at(-1)?.replace(/\.md$/, ""); + const labelSource = prettySegments.at(-1) ?? legacyLeaf ?? "Overview"; + + return { + entry, + id, + prettySegments, + legacySegments, + href: toHref(prettySegments), + legacyHref: toHref(legacySegments, true), + label: isOverview ? "Overview" : labelForSegment(labelSource, prettySegments.length - 1), + }; + }); +} + +export async function getDocEntryById(id: string) { + const entries = await getDocEntries(); + return entries.find((entry) => entry.id === normalizeId(id)); +} + +function sortNodes(nodes: DocTreeNode[]) { + return nodes.sort((left, right) => { + if (left.label === "Overview") return -1; + if (right.label === "Overview") return 1; + + if (left.kind !== right.kind) { + return left.kind === "group" ? -1 : 1; + } + + return left.label.localeCompare(right.label); + }); +} + +export async function getDocTree(): Promise { + const root: TreeGroup = { + kind: "group", + key: "root", + label: "root", + children: new Map(), + }; + + for (const entry of await getDocEntries()) { + const relative = entry.id.replace(/\.md$/, "").split("/").filter(Boolean); + let current = root; + + for (const [index, segment] of relative.entries()) { + const isLast = index === relative.length - 1; + const isOverview = isLast && segment === "index"; + + if (isOverview) { + current.children.set(`overview:${entry.id}`, { + kind: "page", + key: `overview:${entry.id}`, + label: "Overview", + href: entry.href, + }); + continue; + } + + if (isLast) { + current.children.set(`page:${entry.id}`, { + kind: "page", + key: `page:${entry.id}`, + label: labelForSegment(segment, index), + href: entry.href, + }); + continue; + } + + const groupKey = `group:${relative.slice(0, index + 1).join("/")}`; + const existing = current.children.get(groupKey); + + if (existing?.kind === "group") { + current = existing; + continue; + } + + const created: TreeGroup = { + kind: "group", + key: groupKey, + label: labelForSegment(segment, index), + children: new Map(), + }; + + current.children.set(groupKey, created); + current = created; + } + } + + const materialize = (group: TreeGroup): DocTreeNode[] => + sortNodes( + Array.from(group.children.values()).map((child) => { + if (child.kind === "group") { + return { + kind: "group", + key: child.key, + label: child.label, + children: materialize(child), + }; + } + + return { + kind: "page", + key: child.key, + label: child.label, + href: child.href, + }; + }) + ); + + return materialize(root); +} + +export function getBreadcrumbs(entry: DocEntry) { + return entry.prettySegments.map((segment, index) => ({ + label: labelForSegment(segment, index), + href: toHref(entry.prettySegments.slice(0, index + 1)), + })); +} diff --git a/src/lib/icons.ts b/src/lib/icons.ts new file mode 100644 index 00000000..e0c485f2 --- /dev/null +++ b/src/lib/icons.ts @@ -0,0 +1,14 @@ +export const fluentIcons = [ + "arrow-counterclockwise-20-filled", + "chevron-right-20-filled", + "pause-20-filled", + "play-20-filled", +] as const; + +export const playbackIcons = { + play: "fluent:play-20-filled", + pause: "fluent:pause-20-filled", + replay: "fluent:arrow-counterclockwise-20-filled", +} as const; + +export type PlaybackIconName = keyof typeof playbackIcons; diff --git a/src/pages/demo/index.astro b/src/pages/demo/index.astro index 70a68b7e..72f33b05 100644 --- a/src/pages/demo/index.astro +++ b/src/pages/demo/index.astro @@ -1,22 +1,31 @@ --- -import { Markdown } from 'astro/components'; import BaseLayout from "../../layouts/BaseLayout.astro"; - import PlaybackBlock from "../../components/PlaybackBlock.astro"; -import AnimatedBox from '../../components/AnimatedBox.astro'; +import AnimatedBox from "../../components/AnimatedBox.astro"; --- - -
-

- Home / -

+ +
+ - - 1 - 2 - +
+

Demo

+

Interactive playback example for the docs site.

- + + 1 + 2 + +
-
\ No newline at end of file + + + diff --git a/src/pages/docs/[...slug].astro b/src/pages/docs/[...slug].astro new file mode 100644 index 00000000..1db56047 --- /dev/null +++ b/src/pages/docs/[...slug].astro @@ -0,0 +1,38 @@ +--- +import { render } from "astro:content"; +import PagesLayout from "../../layouts/PagesLayout.astro"; +import { getBreadcrumbs, getDocEntries, getDocEntryById } from "../../lib/docs"; + +export const prerender = true; + +export async function getStaticPaths() { + const entries = await getDocEntries(); + + return entries.flatMap((entry) => [ + { + params: { slug: entry.prettySegments.join("/") }, + props: { docId: entry.id }, + }, + { + params: { slug: entry.legacySegments.join("/") }, + props: { docId: entry.id }, + }, + ]); +} + +const { docId } = Astro.props; +const entry = await getDocEntryById(docId); + +if (!entry) { + throw new Error(`Documentation entry not found for ${docId}`); +} + +const { Content } = await render(entry.entry); +const title = entry.entry.data.title ?? entry.label; +const description = entry.entry.data.description; +const breadcrumbs = getBreadcrumbs(entry); +--- + + + + diff --git a/src/pages/docs/index.astro b/src/pages/docs/index.astro new file mode 100644 index 00000000..09ef8778 --- /dev/null +++ b/src/pages/docs/index.astro @@ -0,0 +1,36 @@ +--- +import PagesLayout from "../../layouts/PagesLayout.astro"; +--- + + +

Docs

+

+ Documentation for the native initiative and its packages. Pick a + package below to dive into its overview, API guide, and supporting docs. +

+ +

@okikio/manager

+

+ A superset of the Map class, it extends the Map classes capabilities with awesome new features, and relieves + some of the inconvient quirks of the Map class. +

+ +

@okikio/emitter

+

A small Event Emitter written in typescript with performance and ease of use in mind.

+ +

@okikio/animate

+

+ An animation library for the modern web. Inspired by animate plus, and animejs, it is a Javascript animation + library focused on performance and ease of use. +

+ +

@okikio/native

+

+ A framework to build custom dynamic web experiences around. It acts as a very light weight to build complex web + apps, ranging from PJAX based sites to other Single Page Applications (SPA) based sites and web apps. +

+
diff --git a/src/pages/index.astro b/src/pages/index.astro index 469b9992..31ccc1f9 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,19 +1,68 @@ --- -// import { Markdown } from 'astro/components'; -import { Markdown } from "astro/components"; import BaseLayout from "../layouts/BaseLayout.astro"; - -const content = Astro.fetchContent('../../README.md')[0].astro.source; --- - -
-

- Home / -

+ +
+
- -
+

native

+

+ An initiative which aims to make it easy to create complex, light-weight, and performant web applications + using modern js. The initiative is a monorepo with 4 smaller packages within it, they are + @okikio/manager, @okikio/emitter, + @okikio/animate, and @okikio/native. +

+ +

@okikio/manager

+

+ A superset of the Map class, it extends the Map classes capabilities with awesome new features, and relieves + some of the inconvient quirks of the Map class. +

+

+ Documentation + | + NPM +

+ +

@okikio/emitter

+

A small Event Emitter written in typescript with performance and ease of use in mind.

+

+ Documentation + | + NPM +

+ +

@okikio/animate

+

+ An animation library for the modern web. Inspired by animate plus, and animejs, it is a Javascript animation + library focused on performance and ease of use. It utilizes the Web Animation API (WAAPI) to deliver fluid + animations at a semi-small size. +

+

+ Documentation + | + NPM +

+ +

@okikio/native

+

+ A framework to build custom dynamic web experiences around. It acts as a very light weight to build complex + web apps, ranging from PJAX based sites to other Single Page Applications (SPA) (React, Vue, etc...) based + sites and web apps. +

+

+ Documentation + | + NPM +

+
-
\ No newline at end of file + diff --git a/src/scripts/animate.ts b/src/scripts/animate.ts index 81e8afcc..8e2a87b6 100644 --- a/src/scripts/animate.ts +++ b/src/scripts/animate.ts @@ -1,498 +1,221 @@ -import { animate, tweenAttr, random, queue, AnimateAttributes, SpringEasing, ApplyCustomEasing } from "@okikio/animate"; -import { interpolate } from "polymorph-js"; - -import type { IAnimationOptions, TypePlayStates, Queue, Animate } from "@okikio/animate"; - -// Based on an example by animateplus -/** -( () => { - let containerSel = ".morph-demo"; - let queueInst = queue({ - duration: 1800, - easing: "ease", - loop: 4, - fillMode: "both", - direction: "alternate" - }); +type PlaybackState = "idle" | "running" | "paused" | "finished"; +type PlaybackDatasetState = "play" | "pause" | "replay"; + +const PLAYBACK_STATES: Record = { + idle: "pause", + running: "play", + paused: "pause", + finished: "replay", +}; - queueInst.add(((): IAnimationOptions => { - let usingDPathEl = document.querySelector(`${containerSel} #using-d`); - let InitialStyle = window.getComputedStyle(usingDPathEl); - - return { - target: usingDPathEl, - "d": [ - InitialStyle.getPropertyValue("d"), - `path("M2,5 S2,14 4,5 S7,8 8,4")` - ], - stroke: [ - InitialStyle.getPropertyValue("stroke"), - `rgb(96, 165, 250)` - ], - } - })(), "= 0"); - - queueInst.add(((): IAnimationOptions => { - let usingPolymorphPathEl = document.querySelector(`${containerSel} #using-polymorph-js`); - let InitialStyle = window.getComputedStyle(usingPolymorphPathEl); - - return { - target: usingPolymorphPathEl, - fill: [ - InitialStyle.getPropertyValue("stroke"), - `rgb(96, 165, 250)` - ], - - // You can also use custom properties when animating - // I chose to use them for this example - // Read more here [https://css-tricks.com/a-complete-guide-to-custom-properties/] - "--stroke": [ - InitialStyle.getPropertyValue("stroke"), - `rgb(96, 165, 250)` - ], - }; - })(), "= 0"); - - queueInst.add(((): AnimateAttributes => { - let usingPolymorphPathEl = document.querySelector(`${containerSel} #using-polymorph-js`); - let startPath = usingPolymorphPathEl.getAttribute("d"); - let endPath = - "M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"; - - let morph = interpolate([startPath, endPath], { - addPoints: 0, - origin: { x: 0, y: 0 }, - optimize: "fill", - precision: 3 - }); - - return tweenAttr({ - target: usingPolymorphPathEl, - d: progress => morph(progress), - easing: "linear" - }); - })(), "= 0"); - - playbackFn(containerSel, queueInst); -})(); - -(() => { - let containerSel = ".playback-demo"; - let queueInst = queue(); - - queueInst.add(((): Animate => { - let DOMNodes = document.querySelectorAll(`${containerSel} .el`); - let anim = animate({ - target: DOMNodes, - "background-color": (...args) => { - let [, , target] = args; - let [r, g, b] = [ - random(0, 255), - random(0, 255), - random(0, 255) - ] - return [ - window.getComputedStyle(target).getPropertyValue("background-color"), - `rgb(${r}, ${g}, ${b})` - ]; - }, - - "translate-x": () => [0, random(50, 400)], - translateY(...args) { - let [, total] = args; - return [0, (random(-50, 50) * total)]; - }, - scale() { - return [1, 1 + random(0.025, 1.75)]; - }, - opacity(...args) { - let [, total] = args; - return [0.5, 0.5 + Math.min(random(0.025, total) / total, 0.5)]; - }, - rotate: () => [0, random(-360, 360)], - borderRadius: () => ["3px", `${random(10, 35)}%`], - duration: () => random(1200, 1800), - delay: () => random(0, 400), - - loop: 2, - speed: (i) => 1.5 - (i * 0.125), - - // You can't use `fillMode` if you want to have a queue of animations on the same target, - // instead you can use `onfinish: () => {}` - // fillMode: "both", - - onfinish(...args) { - let [, , , anim] = args; - anim.commitStyles(); - }, - - direction: "alternate", - easing: "in-out-back", - padEndDelay: true, - autoplay: true - }); - - let addBtn = document.querySelector("#add-el") as HTMLElement; - let removeBtn = document.querySelector("#remove-el") as HTMLElement; - let elPlacement = document.querySelector(".el-placement") as HTMLElement; - - let contain = document.createElement("div"); - contain.className = "contain"; - contain.innerHTML = ` -
-
`.trim(); - - addBtn.onclick = () => { - let _contain = contain.cloneNode(true) as HTMLElement; - let el = _contain.querySelector(".el") as HTMLElement; - elPlacement.appendChild(_contain); - - anim.add(el); - - // To support older browsers I can't use partial keyframes - let transition = animate({ - target: _contain, - opacity: [0, 1], - height: [0, "4vmin"], - marginBottom: [0, 5], - fillMode: "forwards", - duration: 400, - easing: "out" - }).then(() => { - transition.stop(); - transition = null; - _contain = null; - el = null; - }); - - }; - - removeBtn.onclick = () => { - let contain = elPlacement.querySelector(".contain") as HTMLElement; - let el = contain?.querySelector(".el") as HTMLElement; - - anim.remove(el); - - // To support older browsers I can't use partial keyframes - let style = window.getComputedStyle(contain); - let marginBottom = style.getPropertyValue("margin-bottom"); - let height = style.getPropertyValue("height"); - let transition = animate({ - target: contain, - opacity: [1, 0], - height: [height, 0], - "margin-bottom": [marginBottom, 0], - fillMode: "forwards", - duration: 400, - easing: "out" - }).then(() => { - contain?.remove(); - transition.stop(); - - transition = null; - contain = null; - el = null; - }); - }; - - return anim; - })(), "= 0"); - - playbackFn(containerSel, queueInst); -})(); - -(() => { - let containerSel = ".motion-path-demo"; - let queueInst = queue({ - padEndDelay: true, - easing: "linear", - duration: 2000, - loop: 4, - speed: 1, - }); +interface DemoElements { + canvas: HTMLElement; + boxes: [HTMLElement, HTMLElement]; + controlButton: HTMLButtonElement; + playback: HTMLElement; + progressBar: HTMLInputElement; + progressOutput: HTMLElement; +} - queueInst.add(((): IAnimationOptions => { - let el = document.querySelector('.motion-path .el-1') as HTMLElement; - - // To support older browsers I can't use partial keyframes - return { - target: el, - "offsetDistance": ["0%", "100%"] - }; - })(), "= 0"); - - queueInst.add(((): IAnimationOptions => { - let path = document.querySelector('.motion-path path') as SVGPathElement; - let el2 = document.querySelector('.motion-path .el-2') as HTMLElement; - - let pts: Set = new Set(); - let rotateArr: number[] = []; - let len = path.getTotalLength(); - - let ptAtZero = path.getPointAtLength(0); - for (var i = 0; i < len; i++) { - let { x, y } = path.getPointAtLength(i); - pts.add([x, y]); - - let { x: x0, y: y0 } = i - 1 >= 1 ? path.getPointAtLength(i - 1) : ptAtZero; - let { x: x1, y: y1 } = i + 1 >= 1 ? path.getPointAtLength(i + 1) : ptAtZero; - let calc = +(Math.atan2(y0 - y1, x0 - x1) * 180 / Math.PI); - rotateArr.push(calc); - } - - return { - target: el2, - translate: [...pts], - rotate: rotateArr, - fillMode: "both", - }; - })(), "= 0"); - - playbackFn(containerSel, queueInst); -})(); - -// I added extra code to the demo to support Chrome 77 and below -function playbackFn(containerSel: string, queueInst: Queue) { - let container = document.querySelector(containerSel); - - let playstateEl = container.querySelector(`#playstate-toggle`) as HTMLInputElement; - let progressEl = container.querySelector(`#progress`) as HTMLInputElement; - let progressOutputEl = container.querySelector(`#progress-output`) as HTMLElement; - - let oldState: "pending" | TypePlayStates; - - let updatePlayState = () => { - oldState = queueInst.getPlayState() as TypePlayStates | "pending"; - if (oldState == "idle") oldState = "paused"; - else if (oldState == "pending") oldState = "running"; - playstateEl.setAttribute("data-playstate", oldState); - }; - - const clickFn = () => { - if (queueInst.is("running")) - queueInst.pause(); - else if (queueInst.is("finished")) - queueInst.reset(); - else - queueInst.play(); - - updatePlayState(); - }; - - const inputFn = () => { - let percent = Number(progressEl.value); - queueInst.pause(); - queueInst.setProgress(percent); - }; - - const changeFn = () => { - if (oldState !== "paused") - queueInst.play(); - else - queueInst.pause(); - - updatePlayState(); - }; - - playstateEl.addEventListener("click", clickFn); - progressEl.addEventListener("input", inputFn); - progressEl.addEventListener("change", changeFn); - - queueInst - .on("finish begin", updatePlayState) - .on({ - update: (progress: number) => { - progress = +(progress.toFixed(4)); - progressEl.value = `` + progress; - progressOutputEl.textContent = `${Math.round(progress)}%`; - }, - stop() { - - updatePlayState(); - playstateEl.removeEventListener("click", clickFn); - progressEl.removeEventListener("input", inputFn); - progressEl.removeEventListener("change", changeFn); - queueInst = null; - } - }); -} - -(() => { - let [translateX, duration] = SpringEasing(["0vw", "50vw"], "spring(5, 100, 10, 1)"); - // let translateX = ["0vw", "50vw"]; - // let duration = 1000; - // let el = document.querySelector('#block-2') as HTMLElement; - // let opts = await animate({ - // target: el, - // "translate-x": ["0vw", "50vw"], - // // translateX, - // // translateY: [0, 500], - // // padEndDelay: true, - // easing: "linear", - // // duration, - // // loop: true, - // speed: 1, - // direction: "alternate" - // }); - // await animate({ - // options: opts, - // "translate-x": ["0vw", "50vw"].reverse(), - // }); - - let queueInst = queue({ - // padEndDelay: true, - easing: "ease", - duration: 2000, - loop: 1, - speed: 1, - direction: "alternate" +class PlaybackTimeline { + private readonly animations: [Animation, Animation]; + private readonly totalDuration: number; + + constructor(animations: [Animation, Animation]) { + this.animations = animations; + this.totalDuration = Math.max(...animations.map((animation) => this.getAnimationEndTime(animation))); + this.pause(); + this.setProgress(0); + } + + play() { + this.animations.forEach((animation) => animation.play()); + } + + pause() { + this.animations.forEach((animation) => animation.pause()); + } + + reset() { + this.animations.forEach((animation) => { + animation.cancel(); + animation.pause(); + animation.currentTime = 0; }); + } - queueInst.add(((): IAnimationOptions => { - let el = document.querySelector('#block') as HTMLElement; - - // To support older browsers I can't use partial keyframes - return { - target: el, - "translate-x": translateX, - }; - })(), "= 0"); - - queueInst.add(((): IAnimationOptions => { - let el = document.querySelector('#block-2') as HTMLElement; - - // To support older browsers I can't use partial keyframes - return { - target: el, - "translate-x": translateX, - }; - })()); - - queueInst.add(((): IAnimationOptions => { - let el = document.querySelector('#block-3') as HTMLElement; - - // To support older browsers I can't use partial keyframes - return { - target: el, - "translate-x": translateX, - }; - })(), "^50"); -})(); -*/ - - -let containerSel = "#simple-demo"; -let el = document.querySelector(`${containerSel} .animated-box`); -let parentEl = el.parentElement as HTMLElement; - -let boundingRect = el.getBoundingClientRect(); -let parentBoundingRect = parentEl.getBoundingClientRect(); - -let { width, height } = boundingRect; -let { width: parentWidth, height: parentHeight } = parentBoundingRect; - -let motionWidth = parseFloat(parentWidth as unknown as string) - parseFloat(width as unknown as string); -let motionHeight = parseFloat(parentHeight as unknown as string) - parseFloat(height as unknown as string); - -let timelineInst = queue(); -timelineInst.add({ - target: el, - translateX: [0, motionWidth], - ...ApplyCustomEasing({ - translateY: [0, motionHeight], - easing: "out-bounce", - duration: 3000 - }), - fillMode: "both", - loop: 5, - easing: "linear", - direction: "alternate" -}); -timelineInst.add({ - target: document.querySelector(`${containerSel} .animated-box#box-2`), - translateX: [0, motionWidth], - ...ApplyCustomEasing({ - translateY: [0, motionHeight], - easing: "out-bounce", - duration: 3000 - }), - fillMode: "both", - loop: 5, - easing: "linear", - direction: "alternate" -}, "^400"); - -setTimeout(() => { - console.clear(); - timelineInst.allInstances((anim) => { - anim.updateOptions({ - translateX: ["0", "200px"] - }); - // console.log(anim); + setProgress(percent: number) { + const currentTime = this.totalDuration * (percent / 100); + this.animations.forEach((animation) => { + animation.pause(); + animation.currentTime = currentTime; }); -}, 3000); + } -playbackFn(containerSel, timelineInst); + getProgress() { + const currentTime = Math.max(...this.animations.map((animation) => this.toNumber(animation.currentTime))); + return (currentTime / this.totalDuration) * 100; + } -let playStates = { - running: "play", - paused: "pause", - finished: "replay", - idle: "pause", - pending: "play" -}; + getState(): PlaybackState { + const [firstAnimation] = this.animations; + const state = firstAnimation.playState; -// I added extra code to the demo to support Chrome 77 and below -function playbackFn(containerSel, timelineInst) { - let container = document.querySelector(containerSel); - - let controlBtn = container.querySelector(`.control-btn`); - let playbackEl = container.querySelector(`.playback`); - let progressBar = container.querySelector(`.progress-bar`); - let progressOutputEl = container.querySelector(`.progress-output`); - - let oldState = timelineInst.getPlayState(); - let updatePlayState = () => { - oldState = timelineInst.getPlayState(); - playbackEl.setAttribute("data-state", playStates[oldState]); - }; - - const clickFn = () => { - if (timelineInst.is("running")) timelineInst.pause(); - else if (timelineInst.is("finished")) timelineInst.reset(); - else timelineInst.play(); - - updatePlayState(); - }; - - const inputFn = () => { - let percent = Number(progressBar.value); - timelineInst.pause(); - timelineInst.setProgress(percent); - }; - - const changeFn = () => { - if (oldState !== "paused") timelineInst.play(); - else timelineInst.pause(); - - updatePlayState(); - }; - - controlBtn.addEventListener("click", clickFn); - progressBar.addEventListener("input", inputFn); - progressBar.addEventListener("change", changeFn); - - timelineInst.on("finish begin", updatePlayState).on({ - update: (progress) => { - progress = +progress.toFixed(4); - progressBar.value = `` + progress; - progressOutputEl.textContent = `${Math.round(progress)}%`; - }, - stop() { - controlBtn.removeEventListener("click", clickFn); - progressBar.removeEventListener("input", inputFn); - progressBar.removeEventListener("change", changeFn); - timelineInst = null; - } - }); + if (state === "running") { + return "running"; + } + + if (state === "paused" && this.toNumber(firstAnimation.currentTime) > 0) { + return "paused"; + } + + if (state === "finished") { + return "finished"; + } + + return "idle"; + } + + private getAnimationEndTime(animation: Animation) { + if (animation.effect instanceof KeyframeEffect) { + return this.toNumber(animation.effect.getComputedTiming().endTime); + } + + return 0; + } + + private toNumber(value: CSSNumberish | null | undefined) { + if (typeof value === "number") { + return value; + } + + if (value && typeof value === "object" && "value" in value && typeof value.value === "number") { + return value.value; + } + + return 0; + } +} + +function getDemoElements(): DemoElements | null { + const container = document.querySelector("#simple-demo"); + const canvas = container?.querySelector(".playback-canvas"); + const boxes = container?.querySelectorAll(".animated-box"); + const controlButton = container?.querySelector(".control-btn"); + const playback = container?.querySelector(".playback"); + const progressBar = container?.querySelector(".progress-bar"); + const progressOutput = container?.querySelector(".progress-output"); + + if ( + !(canvas instanceof HTMLElement) || + !boxes || + boxes.length < 2 || + !(controlButton instanceof HTMLButtonElement) || + !(playback instanceof HTMLElement) || + !(progressBar instanceof HTMLInputElement) || + !(progressOutput instanceof HTMLElement) + ) { + return null; + } + + return { + canvas, + boxes: [boxes[0], boxes[1]], + controlButton, + playback, + progressBar, + progressOutput, + }; +} + +function createAnimation(box: HTMLElement, x: number, y: number, delay = 0) { + return box.animate( + [ + { transform: "translate(0px, 0px)" }, + { transform: `translate(${x}px, ${y}px)` }, + ], + { + delay, + duration: 3000, + direction: "alternate", + easing: "cubic-bezier(0.34, 1.56, 0.64, 1)", + fill: "both", + iterations: 5, + } + ); +} + +function updatePlaybackUi(elements: DemoElements, timeline: PlaybackTimeline) { + const progress = Math.max(0, Math.min(100, timeline.getProgress())); + elements.progressBar.value = `${progress}`; + elements.progressOutput.textContent = `${Math.round(progress)}%`; + elements.playback.dataset.state = PLAYBACK_STATES[timeline.getState()]; +} + +export function startPlaybackDemo() { + const elements = getDemoElements(); + if (!elements) { + return null; + } + + const [primaryBox, secondaryBox] = elements.boxes; + const motionWidth = Math.max(elements.canvas.clientWidth - primaryBox.offsetWidth, 0); + const motionHeight = Math.max(elements.canvas.clientHeight - primaryBox.offsetHeight, 0); + + const timeline = new PlaybackTimeline([ + createAnimation(primaryBox, motionWidth, motionHeight), + createAnimation(secondaryBox, motionWidth, motionHeight, 400), + ]); + + let previousState = timeline.getState(); + let rafId = 0; + + const tick = () => { + updatePlaybackUi(elements, timeline); + + if (timeline.getState() === "running") { + rafId = window.requestAnimationFrame(tick); + } + }; + + const handleClick = () => { + const state = timeline.getState(); + + if (state === "running") { + timeline.pause(); + } else if (state === "finished") { + timeline.reset(); + timeline.play(); + } else { + timeline.play(); + } + + window.cancelAnimationFrame(rafId); + rafId = window.requestAnimationFrame(tick); + previousState = timeline.getState(); + updatePlaybackUi(elements, timeline); + }; + + const handleInput = () => { + timeline.setProgress(Number(elements.progressBar.value)); + updatePlaybackUi(elements, timeline); + }; + + const handleChange = () => { + if (previousState === "running") { + timeline.play(); + rafId = window.requestAnimationFrame(tick); + } else { + timeline.pause(); + } + + updatePlaybackUi(elements, timeline); + }; + + elements.controlButton.addEventListener("click", handleClick); + elements.progressBar.addEventListener("input", handleInput); + elements.progressBar.addEventListener("change", handleChange); + updatePlaybackUi(elements, timeline); + + return timeline; } diff --git a/src/scripts/index.ts b/src/scripts/index.ts index b839825d..d38e9299 100644 --- a/src/scripts/index.ts +++ b/src/scripts/index.ts @@ -1,91 +1,40 @@ -import { App, PJAX, TransitionManager, HistoryManager, PageManager, Router } from "@okikio/native"; -import { animate } from "@okikio/animate"; -import { init, inFocus } from "./sidebar.mjs"; +import { init, inFocus } from "./sidebar"; -import type { ITransition } from "@okikio/native"; +class NavigationDemoController { + private booted = false; -const app = new App(); - -//= Fade Transition -const Fade: ITransition = { - name: "default", - - // Fade Out Old Page - out({ from }) { - let fromWrapper = from.wrapper; - - return animate({ - target: fromWrapper, - opacity: [1, 0], - duration: 500, - }) - }, + boot() { + if (this.booted) { + return; + } - // Fade In New Page - async in({ to }) { - let toWrapper = to.wrapper; + init(); + inFocus(); + window.addEventListener("resize", this.handleResize, { passive: true }); + this.booted = true; + } - await animate({ - target: toWrapper, - opacity: [0, 1], - duration: 500 - }); + stop() { + if (!this.booted) { + return; } -}; - -app - // Note only these 3 Services must be set under the names specified - .set("HistoryManager", new HistoryManager()) - .set("PageManager", new PageManager()) - .set("TransitionManager", new TransitionManager([ - [Fade.name, Fade] - ])) - // The names of these Services don't really matter - // .set("Router", new Router()) - .add(new PJAX()); + window.removeEventListener("resize", this.handleResize); + this.booted = false; + } -try { - // Router is a router, depending on the page path it will run certain tasks - // It support regexp and paths that `path-to-regex` supports, - // however, I might refactor it to use the new `URLPattern` web standard in a future update. - // `URLPattern` accomplishes the same goal in a similar way to `path-to-regex` without needing to install `path-to-regex`. - // I suggest learning more about `URLPattern` on https://web.dev/urlpattern/ - // const router = app.get("Router") as Router; - // router.add({ - // path: "./index?(.html)?", - // method() { - // console.log("Run on Index page"); - // } - // }) + private handleResize = () => { + console.log("App resizing"); + }; +} - // Note these events are emitted by the PJAX Service - app.on({ - HOVER() { - console.log("Print a value on hover over link") - }, - CLICK() { - console.log("Print something when a link is clicked") - }, - NAVIGATION_START() { - console.log("Print before you start loading pages") - }, - READY() { - init(); - inFocus() - }, - CONTENT_REPLACED() { - inFocus(); - } - // etc... - }); +let navigationDemo: NavigationDemoController | null = null; - // While this event is emitted by the App - app.on("resize", () => { - console.log("App resizing"); - }); +export function startNavigationDemo() { + if (!navigationDemo) { + navigationDemo = new NavigationDemoController(); + } - app.boot(); -} catch (e) { - console.warn(e) -} \ No newline at end of file + navigationDemo.boot(); + return navigationDemo; +} diff --git a/src/scripts/sidebar.ts b/src/scripts/sidebar.ts index 5b561e6c..3c372b06 100644 --- a/src/scripts/sidebar.ts +++ b/src/scripts/sidebar.ts @@ -1,156 +1,218 @@ -// Based on https://css-tricks.com/how-to-animate-the-details-element-using-waapi/ +const MOBILE_MEDIA_QUERY = "(max-width: 40rem)"; + +interface SidebarElements { + aside: HTMLElement; + toggle: HTMLButtonElement; + overlay: HTMLButtonElement; +} + class Accordion { - constructor(el) { - // Store the
element - this.el = el; - // Store the element - this.summary = el.querySelector("summary"); - // Store the
element - this.content = el.querySelector(".content"); + private readonly el: HTMLDetailsElement; + private readonly summary: HTMLElement; + private readonly content: HTMLElement; + private animation: Animation | null = null; + private isClosing = false; + private isExpanding = false; + + constructor(el: HTMLDetailsElement) { + const summary = el.querySelector("summary"); + const content = el.querySelector(".content"); + + if (!(summary instanceof HTMLElement) || !(content instanceof HTMLElement)) { + throw new Error("Accordion markup is missing its summary or content element."); + } - // Store the animation object (so we can cancel it if needed) - this.animation = null; - // Store if the element is closing - this.isClosing = false; - // Store if the element is expanding - this.isExpanding = false; - // Detect user clicks on the summary element - this.summary.addEventListener("click", (e) => this.onClick(e)); + this.el = el; + this.summary = summary; + this.content = content; + this.summary.addEventListener("click", this.onClick); } - onClick(e) { - // Stop default behaviour from the browser - e.preventDefault(); - // Add an overflow on the
to avoid content overflowing + // These listeners live on the static sidebar elements for the full page lifecycle, + // so there is no intermediate teardown step before the page unloads. + private onClick = (event: MouseEvent) => { + event.preventDefault(); this.el.style.overflow = "hidden"; - // Check if the element is being closed or is already closed + if (this.isClosing || !this.el.open) { this.open(); - // Check if the element is being openned or is already open - } else if (this.isExpanding || this.el.open) { + return; + } + + if (this.isExpanding || this.el.open) { this.shrink(); } - } + }; - shrink() { - // Set the element as "being closed" + private shrink() { this.isClosing = true; - // Store the current height of the element const startHeight = `${this.el.offsetHeight}px`; - // Calculate the height of the summary const endHeight = `${this.summary.offsetHeight}px`; - // If there is already an animation running - if (this.animation) { - // Cancel the current animation - this.animation.cancel(); - } - - // Start a WAAPI animation + this.animation?.cancel(); this.animation = this.el.animate( - { - // Set the keyframes from the startHeight to endHeight - height: [startHeight, endHeight], - }, - { - duration: 400, - easing: "ease-out", - } + { height: [startHeight, endHeight] }, + { duration: 400, easing: "ease-out" } ); - // When the animation is complete, call onAnimationFinish() this.animation.onfinish = () => this.onAnimationFinish(false); - // If the animation is cancelled, isClosing variable is set to false - this.animation.oncancel = () => (this.isClosing = false); + this.animation.oncancel = () => { + this.isClosing = false; + }; } - open() { - // Apply a fixed height on the element + private open() { this.el.style.height = `${this.el.offsetHeight}px`; - // Force the [open] attribute on the details element this.el.open = true; - // Wait for the next frame to call the expand function window.requestAnimationFrame(() => this.expand()); } - expand() { - // Set the element as "being expanding" + private expand() { this.isExpanding = true; - // Get the current fixed height of the element + const startHeight = `${this.el.offsetHeight}px`; - // Calculate the open height of the element (summary height + content height) - const endHeight = `${this.summary.offsetHeight + - this.content.offsetHeight}px`; - - // If there is already an animation running - if (this.animation) { - // Cancel the current animation - this.animation.cancel(); - } + const endHeight = `${this.summary.offsetHeight + this.content.offsetHeight}px`; - // Start a WAAPI animation + this.animation?.cancel(); this.animation = this.el.animate( - { - // Set the keyframes from the startHeight to endHeight - height: [startHeight, endHeight], - }, - { - duration: 400, - easing: "ease-out", - } + { height: [startHeight, endHeight] }, + { duration: 400, easing: "ease-out" } ); - // When the animation is complete, call onAnimationFinish() + this.animation.onfinish = () => this.onAnimationFinish(true); - // If the animation is cancelled, isExpanding variable is set to false - this.animation.oncancel = () => (this.isExpanding = false); + this.animation.oncancel = () => { + this.isExpanding = false; + }; } - onAnimationFinish(open) { - // Set the open attribute based on the parameter + private onAnimationFinish(open: boolean) { this.el.open = open; - // Clear the stored animation this.animation = null; - // Reset isClosing & isExpanding this.isClosing = false; this.isExpanding = false; - // Remove the overflow hidden and the fixed height - this.el.style.height = this.el.style.overflow = ""; + this.el.style.height = ""; + this.el.style.overflow = ""; + } +} + +function getSidebar() { + return document.querySelector("[data-sidebar]"); +} + +function getSidebarElements(): SidebarElements | null { + const aside = getSidebar(); + const toggle = document.querySelector("[data-sidebar-toggle]"); + const overlay = document.querySelector("[data-sidebar-overlay]"); + + if (!(aside instanceof HTMLElement) || !(toggle instanceof HTMLButtonElement) || !(overlay instanceof HTMLButtonElement)) { + return null; } + + return { aside, toggle, overlay }; +} + +function isMobileViewport() { + return window.matchMedia(MOBILE_MEDIA_QUERY).matches; } -let aside = document.querySelector("aside"); -export const inFocus = () => { - aside.querySelectorAll("a[href]").forEach((element) => { - let cleanURL = (el) => - new URL(el.href, window.location).pathname - .replace(/\/$/, "") - .replace(/\/index$/, ""); - let elURL = cleanURL(element); - let windowURL = cleanURL(window.location); - - if (elURL === windowURL) { - element.classList.add("active"); - - let parent = element.parentElement; - while (parent !== aside) { - if (parent.tagName.toLowerCase() == "details" && !parent.open) { - parent.open = true; - } - - parent = parent.parentElement; +function setSidebarOpen(elements: SidebarElements, open: boolean) { + const shouldOpen = isMobileViewport() ? open : true; + + elements.aside.dataset.open = String(shouldOpen); + elements.overlay.dataset.open = String(shouldOpen); + elements.toggle.setAttribute("aria-expanded", String(shouldOpen)); + elements.toggle.setAttribute( + "aria-label", + shouldOpen ? "Close documentation navigation" : "Open documentation navigation" + ); + elements.overlay.setAttribute("aria-hidden", String(!(shouldOpen && isMobileViewport()))); + + document.body.dataset.sidebarOpen = String(shouldOpen && isMobileViewport()); + + if (shouldOpen && isMobileViewport()) { + const firstFocusable = elements.aside.querySelector( + "a[href], button:not([disabled])" + ); + + firstFocusable?.focus(); + } +} + +function cleanURL(value: string | URL) { + return new URL(value, window.location.href).pathname.replace(/\/$/, "").replace(/\/index$/, ""); +} + +export function inFocus(aside: HTMLElement | null = getSidebar()) { + if (!(aside instanceof HTMLElement)) { + return; + } + + const currentPath = cleanURL(window.location.href); + + aside.querySelectorAll("a[href]").forEach((anchor) => { + anchor.classList.remove("active"); + + if (cleanURL(anchor.href) !== currentPath) { + return; + } + + anchor.classList.add("active"); + + let parent: HTMLElement | null = anchor.parentElement; + while (parent && parent !== aside) { + if (parent instanceof HTMLDetailsElement && !parent.open) { + parent.open = true; } - // element.scrollIntoView(); - element.scrollIntoView({ - behavior: "smooth", - // block: 'start' - }); + parent = parent.parentElement; } + + anchor.scrollIntoView({ behavior: "smooth" }); }); -}; -export const init = () => { - aside.querySelectorAll("details").forEach((el) => { - new Accordion(el); +} + +export function init(aside: HTMLElement | null = getSidebar()) { + if (!(aside instanceof HTMLElement)) { + return; + } + + aside.querySelectorAll("details").forEach((details) => { + if (details.dataset.accordionReady === "true") { + return; + } + + details.dataset.accordionReady = "true"; + new Accordion(details); }); -}; +} + +export function startSidebar() { + const elements = getSidebarElements(); + if (!elements) { + return; + } + + init(elements.aside); + inFocus(elements.aside); + setSidebarOpen(elements, false); + + if (elements.toggle.dataset.bound === "true") { + return; + } + + const syncViewportState = () => setSidebarOpen(elements, elements.aside.dataset.open === "true"); + const closeSidebar = () => setSidebarOpen(elements, false); + const toggleSidebar = () => setSidebarOpen(elements, elements.aside.dataset.open !== "true"); + const onKeydown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closeSidebar(); + } + }; + + elements.toggle.addEventListener("click", toggleSidebar); + elements.overlay.addEventListener("click", closeSidebar); + window.addEventListener("resize", syncViewportState, { passive: true }); + document.addEventListener("keydown", onKeydown); + elements.toggle.dataset.bound = "true"; +} diff --git a/src/style/animate.scss b/src/style/animate.scss deleted file mode 100644 index e4ada9c4..00000000 --- a/src/style/animate.scss +++ /dev/null @@ -1,164 +0,0 @@ -:root { - --size: 4vmin; -} - -.div { - @apply bg-blue-400 w-10 h-10 rounded relative m-2; - --size: 8vmin; - width: var(--size); - height: var(--size); -} - -.morph-demo { - @apply mb-6; -} - -@property --stroke { - syntax: ''; - inherits: false; - initial-value: theme("colors.white"); -} - -.svg-1 path { - fill: none; - stroke: var(--stroke); - stroke-linecap: round; - stroke-linejoin: round; - - - &#using-flubber { - fill: theme("colors.white"); - } -} - -.dark .svg-1 path { - --stroke: theme("colors.tertiary"); - - &#using-flubber { - fill: theme("colors.tertiary"); - } -} - -.add-remove-btns { - position: relative; - z-index: 25; -} - -.el, -.el-initial { - border-radius: 3px; - width: var(--size); - height: var(--size); - // margin-bottom: 5px; - /* transform: scale(1); */ - // border-radius: 8%; - background: #616aff; - position: relative; -} - -.el-initial { - opacity: 0.6; - position: absolute; - display: block; - margin-top: 0; - top: 0; - left: 0; -} - -.motion-path { - position: relative; -} - -.motion-path .el-1 { - position: absolute; - offset-path: path( - "M8,56 C8,33.90861 25.90861,16 48,16 C70.09139,16 88,33.90861 88,56 C88,78.09139 105.90861,92 128,92 C150.09139,92 160,72 160,56 C160,40 148,24 128,24 C108,24 96,40 96,56 C96,72 105.90861,92 128,92 C154,93 168,78 168,56 C168,33.90861 185.90861,16 208,16 C230.09139,16 248,33.90861 248,56 C248,78.09139 230.09139,96 208,96 L48,96 C25.90861,96 8,78.09139 8,56 Z" - ); - // offset-path: url("#follow-path"); - offset-distance: 0; -} - -.motion-path .el-2 { - position: absolute; - transform-origin: center center; - top: calc(var(--size) / -2); - left: calc(var(--size) / -2); -} - -.animation-container { - transition: background-color 0.5s ease; - background-color: rgba(0, 0, 0, 0.085); - border-radius: 5px; - padding: 5px; - cursor: pointer; - - &:hover { - background-color: rgba(255, 255, 255, 0.25); - } -} - -.playback-demo { - @apply overflow-auto; -} - -.contain { - position: relative; - height: var(--size); - margin-bottom: 5px; -} - -.animation-container + .animation-container { - margin-top: 2em; -} - -input[type="range"] { - width: 100%; - padding: 0; - margin: 0; -} -html:not(.unsupported) .support { - display: none; -} - -.col-2 { - display: flex; - justify-content: space-between; - align-items: center; -} -// Cool -#playstate-toggle[data-playstate="running"] { - .fa-play, - .fa-redo { - display: none; - } -} -#playstate-toggle[data-playstate="paused"] { - .fa-pause, - .fa-redo { - display: none; - } -} - -#playstate-toggle[data-playstate="finished"] { - .fa-play, - .fa-pause { - display: none; - } -} - -svg .fa { - fill: currentColor; - width: 24px; - height: 24px; - - path { - fill: currentColor; - width: 100%; - height: 100%; - } -} - -.btn-icon { - display: grid; - place-content: center; -} \ No newline at end of file diff --git a/src/style/app.scss b/src/style/app.scss deleted file mode 100644 index 4df45fc5..00000000 --- a/src/style/app.scss +++ /dev/null @@ -1,164 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; -@tailwind variants; - -@import "./components/navbar.scss"; - -body { - @apply bg-white text-black; - @apply dark:bg-black dark:text-label; - @apply font-manrope; - font-size: 1rem; - line-height: 1.5; -} - -.no-overflow-x { - overflow-x: hidden; -} - -.icon { - -webkit-font-feature-settings: "liga" off, "dlig"; - -moz-font-feature-settings: "liga=0, dlig=1"; - font-feature-settings: "liga", "dlig"; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; - font-family: "Material Icons Round", "Material Icons"; - vertical-align: middle; - letter-spacing: normal; - display: inline-block; - text-decoration: none; - text-transform: none; - white-space: nowrap; - font-weight: normal; - position: relative; - font-style: normal; - word-wrap: normal; - font-size: 24px; - line-height: 1; - direction: ltr; - height: 1em; - width: 1em; -} - -a { - @apply text-blue-500 hover:text-blue-700 underline; - @apply dark:text-blue-400 dark:hover:text-blue-200; -} - -button, -a { - transition: color 0.2s ease-out, box-shadow 0.2s ease-out; - @apply focus-visible:ring-4 focus-visible:ring-blue-500 focus-visible:ring-opacity-50; - @apply focus:outline-none focus-visible:rounded-sm; - - -webkit-tap-highlight-color: transparent; -} - -@screen md { - .desktop { - display: block; - } - - .mobile { - display: none; - } -} - -@screen lt-md { - .mobile { - display: block; - } - - .desktop { - display: none; - } -} - -.navbar-toggle, -.theme-toggle, -.btn-icon { - @apply rounded-md; - @apply mr-1 w-10 h-10 text-blue-500 bg-gray-100 hover:bg-gray-200 active:bg-blue-200; - @apply dark:text-blue-400 dark:bg-quaternary; - @apply dark:hover:bg-gray-800 dark:active:bg-gray-700; - @apply focus-visible:border-blue-600 focus-visible:ring-4 focus:rounded-md; -} - -.btn-icon { - @apply bg-white hover:bg-gray-100 active:bg-blue-200; - @apply dark:text-blue-400 dark:bg-tertiary; - @apply dark:hover:bg-gray-800 dark:active:bg-gray-700; - @apply focus-visible:border-blue-600 focus-visible:ring-4 focus:rounded-md; -} - -.btn-highlight { - @apply rounded-md mr-1 inline-block; - @apply text-blue-500 bg-gray-100 hover:bg-gray-200 active:bg-blue-200; - @apply dark:text-blue-400 dark:bg-quaternary; - @apply dark:hover:bg-gray-800 dark:active:bg-gray-700; - @apply focus-visible:border-blue-600 focus-visible:ring-4 focus:rounded-md; -} - -.btn { - @apply px-3 py-2 rounded-md no-underline hover:bg-gray-200; - @apply dark:text-blue-400 dark:hover:bg-quaternary; - - &.active { - @apply bg-blue-600 text-white hover:bg-blue-700; - @apply dark:bg-blue-400 dark:text-black dark:hover:bg-blue-500; - } - - &:hover { - @apply bg-opacity-60; - } -} - -#big-transition { - position: fixed; - z-index: 2000; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - opacity: 0; - visibility: hidden; -} - -#big-transition #logo { - opacity: 0; - visibility: hidden; -} - -#big-transition #big-transition-horizontal { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; -} - -#big-transition #big-transition-horizontal { - flex-flow: column; -} - -#big-transition #big-transition-horizontal > div { - width: 100%; - flex: 1 1; - @apply bg-gray-200 dark:bg-quaternary; - @apply border-4 border-gray-200; - @apply dark:border-quaternary; - transform-origin: 0 0; - transform: scaleX(0); -} - -#logo { - position: absolute; - z-index: 12; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} diff --git a/src/style/base.css b/src/style/base.css new file mode 100644 index 00000000..6b9e726b --- /dev/null +++ b/src/style/base.css @@ -0,0 +1,288 @@ +/* Sakura.css v1.3.1 + * ================ + * Minimal css theme. + * Project: https://github.com/oxalorg/sakura/ + */ + +:root { + --color-force: #3b82f6; + --color-blossom: color-mix(in srgb, var(--color-force) 80%, white); + --color-fade: var(--color-force); + --color-bg: #120c0e; + --color-bg-alt: color-mix(in srgb, #1e3a8a 80%, black); + --color-text: #d9d8dc; + --light-external-icon: url(/assets/light-external.svg); + --dark-external-icon: url(/assets/dark-external.svg); + --external-icon: var(--light-external-icon); + --font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", + sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --external-icon: var(--dark-external-icon); + } +} + +html { + font-size: 62.5%; + font-family: var(--font-family-base); + scroll-padding-top: 15vh; + scrollbar-gutter: stable both-edges; +} + +body { + font-size: 1.8rem; + line-height: 1.618; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: 1.1; + font-weight: 700; + margin-top: 3rem; + margin-bottom: 1.5rem; + overflow-wrap: break-word; + word-wrap: break-word; + -ms-word-break: break-all; + word-break: break-word; +} + +h1 a, +h2 a, +h3 a, +h4 a, +h5 a, +h6 a, +a :is(h1, h2, h3, h4, h5, h6) { + @apply no-underline; +} + +h1 { + font-size: 2.35em; +} + +h2 { + font-size: 2em; +} + +h3 { + font-size: 1.75em; +} + +h4 { + font-size: 1.5em; +} + +h5 { + font-size: 1.25em; +} + +h6 { + font-size: 1em; +} + +p { + margin-top: 0; + margin-bottom: 2.5rem; +} + +small, +sub, +sup { + font-size: 75%; +} + +hr { + border-color: var(--color-blossom); +} + +a { + text-decoration: underline; + color: var(--color-blossom); +} + +@media (prefers-color-scheme: light) { + a { + color: color-mix(in srgb, var(--color-blossom) 65%, black); + } +} + +a:hover { + color: var(--color-fade); + border-bottom: 2px solid var(--color-text); +} + +@media (prefers-color-scheme: light) { + a:hover { + border-bottom-color: color-mix(in srgb, var(--color-blossom) 75%, black); + } +} + +a:visited { + color: color-mix(in srgb, var(--color-blossom) 90%, black); + border-bottom: 2px solid var(--color-text); + text-decoration: double !important; +} + +@media (prefers-color-scheme: light) { + a:visited { + @apply text-indigo-700 font-bold; + border-bottom-color: color-mix(in srgb, var(--color-blossom) 65%, black); + } +} + +ul { + padding-left: 1.4em; + margin-top: 0; + margin-bottom: 2.5rem; +} + +li { + margin-bottom: 0.4em; +} + +blockquote { + margin-left: 0; + margin-right: 0; + padding: 0.8em 0.8em 0.8em 1em; + border-left: 5px solid var(--color-blossom); + margin-bottom: 2.5rem; + background-color: color-mix(in srgb, var(--color-bg-alt) 76%, white); +} + +blockquote p { + margin-bottom: 0; +} + +img, +video { + height: auto; + max-width: 100%; + margin-top: 0; + margin-bottom: 2.5rem; +} + +pre { + display: block; + padding-block: 1em; + overflow-x: auto; + margin-top: 0; +} + +code { + font-size: 0.9em; + padding: 0 0.5em; + white-space: pre-wrap; +} + +pre > code { + padding: 0; + background-color: transparent; + white-space: pre; +} + +table { + text-align: justify; + width: 100%; + border-collapse: collapse; +} + +td, +th { + padding: 0.5em; + border-bottom: 1px solid #313e47; +} + +input, +textarea { + border: 1px solid var(--color-text); +} + +input:focus, +textarea:focus { + border: 1px solid var(--color-blossom); +} + +textarea { + width: 100%; +} + +.button, +button, +input[type="submit"], +input[type="reset"], +input[type="button"] { + display: inline-block; + padding: 5px 10px; + text-align: center; + text-decoration: none; + white-space: nowrap; + background-color: var(--color-blossom); + color: var(--color-bg); + border-radius: 1px; + border: 1px solid var(--color-blossom); + cursor: pointer; + box-sizing: border-box; +} + +.button[disabled], +button[disabled], +input[type="submit"][disabled], +input[type="reset"][disabled], +input[type="button"][disabled] { + cursor: default; + opacity: 0.5; +} + +.button:focus:enabled, +.button:hover:enabled, +button:focus:enabled, +button:hover:enabled, +input[type="submit"]:focus:enabled, +input[type="submit"]:hover:enabled, +input[type="reset"]:focus:enabled, +input[type="reset"]:hover:enabled, +input[type="button"]:focus:enabled, +input[type="button"]:hover:enabled { + background-color: var(--color-fade); + border-color: var(--color-fade); + color: var(--color-bg); + outline: 0; +} + +textarea, +select, +input { + color: var(--color-text); + padding: 6px 10px; + margin-bottom: 10px; + background-color: var(--color-bg-alt); + border: 1px solid var(--color-bg-alt); + border-radius: 4px; + box-shadow: none; + box-sizing: border-box; +} + +textarea:focus, +select:focus, +input:focus { + border: 1px solid var(--color-blossom); + outline: 0; +} + +input[type="checkbox"]:focus { + outline: 1px dotted var(--color-blossom); +} + +label, +legend, +fieldset { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; +} diff --git a/src/style/base.scss b/src/style/base.scss deleted file mode 100644 index d5729048..00000000 --- a/src/style/base.scss +++ /dev/null @@ -1,316 +0,0 @@ -/* Sakura.css v1.3.1 - * ================ - * Minimal css theme. - * Project: https://github.com/oxalorg/sakura/ - */ - -$color-force: #3b82f6; -$color-blossom: lighten($color-force, 20%); -$color-fade: $color-force; - -$color-bg: #120c0e; -$color-bg-alt: darken(#1e3a8a, 20%); - -/* $color-text: #dedce5; */ -$color-text: #d9d8dc; -$font-size-base: 1.8rem; - -$font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Helvetica Neue", Arial, "Noto Sans", sans-serif; -$font-family-heading: $font-family-base; - -:root { - --light-external-icon: url(/assets/light-external.svg); - --dark-external-icon: url(/assets/dark-external.svg); - --external-icon: var(--light-external-icon); - - @media (prefers-color-scheme: dark) { - --external-icon: var(--dark-external-icon); - } -} - -/* Body */ -html { - font-size: 62.5%; // So that root size becomes 10px - font-family: $font-family-base; - // scroll-padding-block-start: theme("spacing.24"); - // scroll-margin-block-start: theme("spacing.24"); - scroll-padding-top: 15vh; - - scrollbar-gutter: stable both-edges; -} - -body { - // $font-size-base must be a rem value - font-size: $font-size-base; - line-height: 1.618; - @apply text-3xl; - // max-width: 38em; - color: $color-text; - background-color: $color-bg; - font-size: $font-size-base * 0.95; -} - -@media (max-width: 684px) { - body { - font-size: $font-size-base * 0.85; - } -} - -@media (max-width: 382px) { - body { - font-size: $font-size-base * 0.75; - } -} - -@mixin word-wrap() { - overflow-wrap: break-word; - word-wrap: break-word; - -ms-word-break: break-all; - word-break: break-word; -} - -h1, -h2, -h3, -h4, -h5, -h6 { - line-height: 1.1; - // font-family: $font-family-heading; - font-weight: 700; - margin-top: 3rem; - margin-bottom: 1.5rem; - @include word-wrap; - - a { - @apply no-underline; - } -} - -a :is(h1, h2, h3, h4, h5, h6) { - @apply no-underline; -} - -h1 { - font-size: 2.35em; -} -h2 { - font-size: 2em; -} -h3 { - font-size: 1.75em; -} -h4 { - font-size: 1.5em; -} -h5 { - font-size: 1.25em; -} -h6 { - font-size: 1em; -} - -p { - margin-top: 0px; - margin-bottom: 2.5rem; -} - -small, -sub, -sup { - font-size: 75%; -} - -hr { - border-color: $color-blossom; -} - -a { - // text-decoration: none; - text-decoration: underline; - color: $color-blossom; - - @media (prefers-color-scheme: light) { - color: darken($color-blossom, 35%); - } - - // &[target]:not([href*="https://gitpod.io/#"]):after - // { - // display: inline; - // content: var(--external-icon); - // stroke: currentColor; - // margin-left: 0.2rem; - // margin-right: 0.1rem; - // opacity: 0.6; - // transition: all 0.2s 50ms; - // } - - &:hover { - color: $color-fade; - border-bottom: 2px solid $color-text; - - @media (prefers-color-scheme: light) { - border-bottom-color: darken($color-blossom, 25%); - } - } - - &:visited { - color: darken($color-blossom, 10%); - border-bottom: 2px solid $color-text; - text-decoration: double !important; - - @media (prefers-color-scheme: light) { - @apply text-indigo-700 font-bold; - border-bottom-color: darken($color-blossom, 35%); - } - } -} - -ul { - padding-left: 1.4em; - margin-top: 0px; - margin-bottom: 2.5rem; -} - -li { - margin-bottom: 0.4em; -} - -blockquote { - margin-left: 0px; - margin-right: 0px; - padding-left: 1em; - padding-top: 0.8em; - padding-bottom: 0.8em; - padding-right: 0.8em; - border-left: 5px solid $color-blossom; - margin-bottom: 2.5rem; - background-color: lighten($color-bg-alt, 24%); -} - -blockquote p { - margin-bottom: 0; -} - -img, -video { - height: auto; - max-width: 100%; - margin-top: 0px; - margin-bottom: 2.5rem; -} - -/* Pre and Code */ - -pre { - // background-color: $color-bg-alt; - display: block; - padding-block: 1em; - overflow-x: auto; - margin-top: 0px; -} - -code { - font-size: 0.9em; - padding: 0 0.5em; - // background-color: $color-bg-alt; - white-space: pre-wrap; -} - -pre > code { - padding: 0; - background-color: transparent; - white-space: pre; -} - -/* Tables */ - -table { - text-align: justify; - width: 100%; - border-collapse: collapse; -} - -td, -th { - padding: 0.5em; - border-bottom: 1px solid #313e47; -} - -/* Buttons, forms and input */ - -input, -textarea { - border: 1px solid $color-text; - - &:focus { - border: 1px solid $color-blossom; - } -} - -textarea { - width: 100%; -} - -.button, -button, -input[type="submit"], -input[type="reset"], -input[type="button"] { - display: inline-block; - padding: 5px 10px; - text-align: center; - text-decoration: none; - white-space: nowrap; - - background-color: $color-blossom; - color: $color-bg; - border-radius: 1px; - border: 1px solid $color-blossom; - cursor: pointer; - box-sizing: border-box; - - &[disabled] { - cursor: default; - opacity: 0.5; - } - - &:focus:enabled, - &:hover:enabled { - background-color: $color-fade; - border-color: $color-fade; - color: $color-bg; - outline: 0; - } -} - -textarea, -select, -input { - color: $color-text; - padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ - margin-bottom: 10px; - background-color: $color-bg-alt; - border: 1px solid $color-bg-alt; - border-radius: 4px; - box-shadow: none; - box-sizing: border-box; - - &:focus { - border: 1px solid $color-blossom; - outline: 0; - } -} - -input[type="checkbox"]:focus { - outline: 1px dotted $color-blossom; -} - -label, -legend, -fieldset { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; -} diff --git a/src/style/components/_hightlightjs.scss b/src/style/components/_hightlightjs.scss deleted file mode 100644 index 5596919e..00000000 --- a/src/style/components/_hightlightjs.scss +++ /dev/null @@ -1,214 +0,0 @@ -code { - @apply border border-gray-500 dark:border-gray-500; - // shadow-lg -} - -code { - color: #24292e; - background: #fff; - - @apply px-2 py-1 rounded-lg; - font-family: Consolas, "Courier New", monospace; - font-weight: normal; - font-size: 14px; - font-feature-settings: "liga" 0, "calt" 0; - line-height: 19px; - - font-weight: 600; - @apply leading-5; -} - -@media (prefers-color-scheme: dark) { - code { - color: #adbac7; - background: #22272e; - } -} - -pre code { - @apply rounded-lg; - display: block; - overflow-x: auto; - padding: 15px; - line-height: 19px; -} - -/*! - Theme: GitHub - Description: Light theme as seen on github.com - Author: github.com - Maintainer: @Hirse - Updated: 2021-05-15 - - Outdated base version: https://github.com/primer/github-syntax-light - Current colors taken from GitHub's CSS -*/ -code { - color: #24292e; - background: #fff; -} -.hljs-doctag, -.hljs-keyword, -.hljs-meta .hljs-keyword, -.hljs-template-tag, -.hljs-template-variable, -.hljs-type, -.hljs-variable.language_ { - color: #d73a49; -} -.hljs-title, -.hljs-title.class_, -.hljs-title.class_.inherited__, -.hljs-title.function_ { - color: #6f42c1; -} -.hljs-attr, -.hljs-attribute, -.hljs-literal, -.hljs-meta, -.hljs-number, -.hljs-operator, -.hljs-selector-attr, -.hljs-selector-class, -.hljs-selector-id, -.hljs-variable { - color: #005cc5; -} -.hljs-meta .hljs-string, -.hljs-regexp, -.hljs-string { - color: #032f62; -} -.hljs-built_in, -.hljs-symbol { - color: #e36209; -} -.hljs-code, -.hljs-comment, -.hljs-formula { - color: #6a737d; -} -.hljs-name, -.hljs-quote, -.hljs-selector-pseudo, -.hljs-selector-tag { - color: #22863a; -} -.hljs-subst { - color: #24292e; -} -.hljs-section { - color: #005cc5; - font-weight: 700; -} -.hljs-bullet { - color: #735c0f; -} -.hljs-emphasis { - color: #24292e; - font-style: italic; -} -.hljs-strong { - color: #24292e; - font-weight: 700; -} -.hljs-addition { - color: #22863a; - background-color: #f0fff4; -} -.hljs-deletion { - color: #b31d28; - background-color: #ffeef0; -} - -/*! - Theme: GitHub Dark Dimmed - Description: Dark dimmed theme as seen on github.com - Author: github.com - Maintainer: @Hirse - Updated: 2021-05-15 - - Colors taken from GitHub's CSS -*/ - -@media (prefers-color-scheme: dark) { - .dark { - code { - color: #adbac7; - background: #22272e; - } - .hljs-doctag, - .hljs-keyword, - .hljs-meta .hljs-keyword, - .hljs-template-tag, - .hljs-template-variable, - .hljs-type, - .hljs-variable.language_ { - color: #f47067; - } - .hljs-title, - .hljs-title.class_, - .hljs-title.class_.inherited__, - .hljs-title.function_ { - color: #dcbdfb; - } - .hljs-attr, - .hljs-attribute, - .hljs-literal, - .hljs-meta, - .hljs-number, - .hljs-operator, - .hljs-selector-attr, - .hljs-selector-class, - .hljs-selector-id, - .hljs-variable { - color: #6cb6ff; - } - .hljs-meta .hljs-string, - .hljs-regexp, - .hljs-string { - color: #96d0ff; - } - .hljs-built_in, - .hljs-symbol { - color: #f69d50; - } - .hljs-code, - .hljs-comment, - .hljs-formula { - color: #768390; - } - .hljs-name, - .hljs-quote, - .hljs-selector-pseudo, - .hljs-selector-tag { - color: #8ddb8c; - } - .hljs-subst { - color: #adbac7; - } - .hljs-section { - color: #316dca; - font-weight: 700; - } - .hljs-bullet { - color: #eac55f; - } - .hljs-emphasis { - color: #adbac7; - font-style: italic; - } - .hljs-strong { - color: #adbac7; - font-weight: 700; - } - .hljs-addition { - color: #b4f1b4; - background-color: #1b4721; - } - .hljs-deletion { - color: #ffd8d3; - background-color: #78191b; - } - } -} diff --git a/src/style/components/highlightjs.css b/src/style/components/highlightjs.css new file mode 100644 index 00000000..e472f5b3 --- /dev/null +++ b/src/style/components/highlightjs.css @@ -0,0 +1,206 @@ +code { + @apply border border-gray-500 dark:border-gray-500 px-2 py-1 rounded-lg leading-5; + color: #24292e; + background: #fff; + font-family: Consolas, "Courier New", monospace; + font-weight: 600; + font-size: 14px; + font-feature-settings: "liga" 0, "calt" 0; + line-height: 19px; +} + +@media (prefers-color-scheme: dark) { + code { + color: #adbac7; + background: #22272e; + } +} + +pre code { + @apply rounded-lg; + display: block; + overflow-x: auto; + padding: 15px; + line-height: 19px; +} + +.hljs-doctag, +.hljs-keyword, +.hljs-meta .hljs-keyword, +.hljs-template-tag, +.hljs-template-variable, +.hljs-type, +.hljs-variable.language_ { + color: #d73a49; +} + +.hljs-title, +.hljs-title.class_, +.hljs-title.class_.inherited__, +.hljs-title.function_ { + color: #6f42c1; +} + +.hljs-attr, +.hljs-attribute, +.hljs-literal, +.hljs-meta, +.hljs-number, +.hljs-operator, +.hljs-selector-attr, +.hljs-selector-class, +.hljs-selector-id, +.hljs-variable { + color: #005cc5; +} + +.hljs-meta .hljs-string, +.hljs-regexp, +.hljs-string { + color: #032f62; +} + +.hljs-built_in, +.hljs-symbol { + color: #e36209; +} + +.hljs-code, +.hljs-comment, +.hljs-formula { + color: #6a737d; +} + +.hljs-name, +.hljs-quote, +.hljs-selector-pseudo, +.hljs-selector-tag { + color: #22863a; +} + +.hljs-subst { + color: #24292e; +} + +.hljs-section { + color: #005cc5; + font-weight: 700; +} + +.hljs-bullet { + color: #735c0f; +} + +.hljs-emphasis { + color: #24292e; + font-style: italic; +} + +.hljs-strong { + color: #24292e; + font-weight: 700; +} + +.hljs-addition { + color: #22863a; + background-color: #f0fff4; +} + +.hljs-deletion { + color: #b31d28; + background-color: #ffeef0; +} + +@media (prefers-color-scheme: dark) { + code { + color: #adbac7; + background: #22272e; + } + + .hljs-doctag, + .hljs-keyword, + .hljs-meta .hljs-keyword, + .hljs-template-tag, + .hljs-template-variable, + .hljs-type, + .hljs-variable.language_ { + color: #f47067; + } + + .hljs-title, + .hljs-title.class_, + .hljs-title.class_.inherited__, + .hljs-title.function_ { + color: #dcbdfb; + } + + .hljs-attr, + .hljs-attribute, + .hljs-literal, + .hljs-meta, + .hljs-number, + .hljs-operator, + .hljs-selector-attr, + .hljs-selector-class, + .hljs-selector-id, + .hljs-variable { + color: #6cb6ff; + } + + .hljs-meta .hljs-string, + .hljs-regexp, + .hljs-string { + color: #96d0ff; + } + + .hljs-built_in, + .hljs-symbol { + color: #f69d50; + } + + .hljs-code, + .hljs-comment, + .hljs-formula { + color: #768390; + } + + .hljs-name, + .hljs-quote, + .hljs-selector-pseudo, + .hljs-selector-tag { + color: #8ddb8c; + } + + .hljs-subst, + .hljs-emphasis, + .hljs-strong { + color: #adbac7; + } + + .hljs-section { + color: #316dca; + font-weight: 700; + } + + .hljs-bullet { + color: #eac55f; + } + + .hljs-emphasis { + font-style: italic; + } + + .hljs-strong { + font-weight: 700; + } + + .hljs-addition { + color: #b4f1b4; + background-color: #1b4721; + } + + .hljs-deletion { + color: #ffd8d3; + background-color: #78191b; + } +} diff --git a/src/style/components/navbar.scss b/src/style/components/navbar.scss deleted file mode 100644 index 2a86b8b0..00000000 --- a/src/style/components/navbar.scss +++ /dev/null @@ -1,124 +0,0 @@ - - -.navbar { - @apply fixed top-0 left-0 w-full z-50; - - @supports (backdrop-filter: blur(5px)) { - @apply backdrop-filter backdrop-blur dark:backdrop-blur-lg; - } - - .container { - @apply sm:max-w-screen-xl px-5 h-full relative; - } - - .navbar-bg { - // transition: background-color ease 0.25s; - @apply absolute top-0 left-0 w-full h-full px-5; - @apply bg-white dark:bg-black; - } - - &.active-shadow .navbar-bg { - @apply dark:bg-elevated; - } - - .navbar-bg, - &.active-shadow .navbar-bg { - @supports (backdrop-filter: blur(5px)) { - @apply dark:bg-opacity-60; - } - } - - .navbar-frame { - @apply flex flex-wrap items-center justify-between relative; - @apply w-full py-2; - } - - .navbar-shadow { - transition: opacity ease 0.25s; - box-shadow: 0 4px 15px rgb(0 0 50 / 8%); - @apply absolute top-0 left-0 w-full h-full; - @apply opacity-0; - } - - &.active-shadow .navbar-shadow { - @apply opacity-100; - } - - .navbar-border { - // transition: border-color ease 0.25s; - @apply absolute top-0 left-0 w-full h-full; - @apply border-b border-gray-200; - @apply dark:border-elevated; - } - - &.active-shadow .navbar-border { - @apply border-gray-300; - @apply dark:border-tertiary; - } -} - -.navbar-offset { - @apply mt-16; -} - -.navbar-collapse { - @apply items-center; - - @screen lt-md { - --height: 100vh; - transition: height ease-out 0.35s; - @apply flex-grow; - flex-basis: 100%; - overflow-y: hidden; - - &.show { - height: 100%; - height: var(--height, 100vh); - } - - &.collapse { - height: 0; - } - } -} - -.navbar-list { - @apply flex lt-md:flex-col flex-nowrap px-1; -} - -.navbar-logo { - @apply dark:hover:text-blue-500; - @apply text-xl font-bold ml-1; -} - -.navbar-list a { - @apply md:mx-1 lt-md:w-full lt-md:my-1; - @apply font-medium; -} - -.navbar-logo, -.navbar-list a { - @apply px-3 py-2 rounded-md no-underline hover:bg-gray-200; - @apply dark:text-blue-400 dark:hover:bg-quaternary; - - &.active { - @apply bg-blue-600 text-white hover:bg-blue-700; - @apply dark:bg-blue-400 dark:text-black dark:hover:bg-blue-500; - } - - &:hover { - @apply bg-opacity-60; - } -} - -.navbar-toggle, -.theme-toggle, -.navbar-logo, -.navbar-list a { - transition: border-color 0.2s ease-out, box-shadow 0.2s ease-out; - - @screen md { - transition: background-color 0.15s ease-out, color 0.15s ease-out, - border-color 0.2s ease-out, box-shadow 0.2s ease-out; - } -} \ No newline at end of file diff --git a/src/style/global.css b/src/style/global.css new file mode 100644 index 00000000..4c7e386b --- /dev/null +++ b/src/style/global.css @@ -0,0 +1,97 @@ +@import "@fontsource-variable/lexend"; +@import "@fontsource-variable/geist-mono"; + +@import "./tailwind-reference.css"; + +@import "./base.css"; +@import "./components/highlightjs.css"; + +:root { + --font-lexend: "Lexend Variable"; + --font-geist-mono: "Geist Mono Variable"; +} + +body { + @apply bg-white text-slate-900 font-sans; + line-height: 1.6; + + @media (prefers-color-scheme: dark) { + @apply bg-black text-label; + } +} + +body[data-sidebar-open="true"] { + overflow: hidden; +} + +:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 4px; +} + +a { + word-break: break-word; +} + +.layout { + @apply relative flex min-h-screen; + + @supports (padding-inline-start: env(safe-area-inset-left)) { + padding-inline-start: env(safe-area-inset-left); + padding-inline-end: env(safe-area-inset-right); + padding-block-start: env(safe-area-inset-top); + padding-block-end: env(safe-area-inset-bottom); + } +} + +main[data-wrapper] { + @apply min-w-0 w-full p-3 md:p-6 xl:p-8; +} + +.page-shell, +article[main-content] { + @apply w-full max-w-5xl mx-auto; +} + +.page-shell { + @apply space-y-6; +} + +.breadcrumbs { + @apply flex flex-wrap items-center gap-2 text-sm text-slate-500; + + @media (prefers-color-scheme: dark) { + @apply text-slate-400; + } +} + +.breadcrumbs a { + @apply no-underline; +} + +article[main-content] > :last-child { + margin-bottom: 0; +} + +details p { + @apply m-0; +} + +main ul { + list-style-type: disc; +} + +main ul li { + margin-bottom: 0.2em; +} + +code, +pre code { + font-family: var(--font-geist-mono), ui-monospace, monospace; +} + +@media (width < 40rem) { + :is(h1, h2, h3, h4, h5, h6, p, a) { + word-break: break-word; + } +} diff --git a/src/style/global.scss b/src/style/global.scss deleted file mode 100644 index 6c05ed6d..00000000 --- a/src/style/global.scss +++ /dev/null @@ -1,276 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@import "./base.scss"; -@import "./components/hightlightjs"; - -@font-face { - font-family: "FluentSystemIcons-Regular"; - src: url("/fonts/FluentSystemIcons-Regular.ttf") format("truetype"); -} - -.icon:before { - font-family: FluentSystemIcons-Regular !important; - font-style: normal; - font-weight: normal !important; - font-variant: normal; - text-transform: none; - line-height: 1; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -.icon-link:before { - content: "\f4e5"; -} - -body { - @apply bg-white text-black; - @apply dark:bg-black dark:text-label; - @apply font-lexend; - font-weight: 400; - // font-size: 1rem; - line-height: 1.5; -} - -:focus { - outline-color: theme("colors.blue.500"); - - @media (prefers-color-scheme: dark) { - outline-color: theme("colors.blue.500"); - } -} - -blockquote { - @apply rounded-lg; - background-color: theme("colors.blue.100"); - - @media (prefers-color-scheme: dark) { - background-color: $color-bg-alt; - } -} - -details p { - @apply m-0; -} - -details { - @apply cursor-pointer; -} - -:is(h1, h2, h3, h4, h5, h6, p, a) { - @screen lt-sm { - word-break: break-all; - } -} - -p { - // @apply font-manrope font-normal; -} - -a { - word-break: break-all; -} - -// .icon { -// -webkit-font-feature-settings: "liga" off, "dlig"; -// -moz-font-feature-settings: "liga=0, dlig=1"; -// font-feature-settings: "liga", "dlig"; -// -webkit-font-smoothing: antialiased; -// -moz-osx-font-smoothing: grayscale; -// text-rendering: optimizeLegibility; -// font-family: "Material Icons Round", "Material Icons"; -// vertical-align: middle; -// letter-spacing: normal; -// display: inline-block; -// text-decoration: none; -// text-transform: none; -// white-space: nowrap; -// font-weight: normal; -// position: relative; -// font-style: normal; -// word-wrap: normal; -// font-size: 24px; -// line-height: 1; -// direction: ltr; -// height: 1em; -// width: 1em; -// } - -h1, -h2, -h3, -h4, -h5, -h6 { - .icon { - visibility: hidden; - font-size: 0.75em; - } - - a { - @apply p-2; - } - - &:hover .icon { - visibility: visible; - border-bottom-color: transparent; - } - - a:is(:hover, :focus) { - visibility: visible; - border-bottom-color: transparent; - } -} - -.layout { - @apply flex relative; - @supports (padding-inline-start: env(safe-area-inset-left)) { - padding-inline-start: env(safe-area-inset-left); - padding-inline-end: env(safe-area-inset-right); - - padding-block-start: env(safe-area-inset-top); - padding-block-end: env(safe-area-inset-bottom); - } -} -main[data-wrapper] { - @apply w-full; - @apply p-3; -} - -article[main-content] { - @apply md:grid gap-x-10 xl:gap-x-16 w-full; - - // @screen sm { - // grid-template-columns: minmax(0, 1fr) 200px; - // } - - @screen md { - grid-template-columns: minmax(0, 1fr) 300px; - } -} -.markdown-body { - // container - @apply block max-w-screen-lg w-full mx-auto; - @apply p-3; -} - -main ul { - list-style-type: disc; - - li { - margin-bottom: 0.2em; - } -} - -.toc { - @apply md:text-left pt-48; - - .toc-sticky { - @apply sticky top-0 pb-2 pr-2 overflow-x-visible; - height: calc(100vh - theme("spacing.32")); - top: calc(theme("spacing.32") - theme("spacing.10")); - - &:before, - &:after { - @apply absolute left-0 from-white dark:from-black to-transparent z-10 flex-shrink-0; - @apply block w-full h-16 pointer-events-none; - content: ""; - } - - &:before { - @apply top-0 bg-gradient-to-b; - transform: translateY( - calc( - theme("fontSize.2xl[0]") + - theme("fontSize.2xl.1.lineHeight") - ) - ); - } - - &:after { - @apply bottom-0 bg-gradient-to-t; - } - - @supports (height: 100dvh) { - height: calc(100dvh - theme("spacing.32")); - } - } - - .toc-title { - @apply text-2xl uppercase relative z-10; - transform: translateY(calc(theme("spacing.10"))); - } - - a { - @apply inline-block border-none text-black/75 dark:text-blue-300/80; - @apply py-1 no-underline hover:underline; - @apply font-light; - word-break: break-word; - font-size: 0.9em; - - &:is(:hover, :focus) { - text-decoration-color: black; - - @media (prefers-color-scheme: dark) { - text-decoration-color: white; - } - } - - &:focus { - @apply font-normal; - } - } - - .toc-level-1 { - @apply py-10 overflow-y-auto w-full; - height: calc( - 100% - theme("fontSize.2xl[0]") - theme("fontSize.2xl.1.lineHeight") - ); - - & > .toc-item > a { - @apply text-blue-500; - } - } - - .toc-item .toc-level { - padding-inline-start: 1em; - @apply py-1 relative; - - li { - @apply mb-0; - } - - a { - @apply py-2 border-l-[1px] border-dashed border-gray-500/60; - @apply hover:border-b-0; - padding-inline-start: 1em; - - &:focus { - @apply text-blue-500; - @apply border-b-0; - @apply border-solid border-blue-500/80; - } - } - } - - .toc-level-2 a { - // font-size: 0.7em; - @apply py-2; - } -} - -// a { -// @apply text-blue-500 hover:text-blue-700 underline; -// @apply dark:text-blue-400 dark:hover:text-blue-200; -// } - -// button, -// a { -// transition: color 0.2s ease-out, box-shadow 0.2s ease-out; -// @apply focus-visible:ring-4 focus-visible:ring-blue-500 focus-visible:ring-opacity-50; -// @apply focus:outline-none focus-visible:rounded-sm; - -// -webkit-tap-highlight-color: transparent; -// } diff --git a/src/style/tailwind-reference.css b/src/style/tailwind-reference.css new file mode 100644 index 00000000..aa63d069 --- /dev/null +++ b/src/style/tailwind-reference.css @@ -0,0 +1,12 @@ +@import "tailwindcss"; + +@theme { + --font-sans: var(--font-lexend), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, monospace; + --color-label: #d9d8dc; + --color-tertiary: #172033; + --color-quaternary: #111827; + --color-elevated-2: #101828; + --color-focus: #60a5fa; + --color-accent: #3b82f6; +} diff --git a/tsconfig.json b/tsconfig.json index 7177d4f3..507f45bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,8 @@ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "astro.config.ts", "src/**/*"], + "exclude": ["dist", "node_modules", "packages", "repl.ts", "strong-spectrum"], "compilerOptions": { // Enable strict mode. This enables a few options at a time, see https://www.typescriptlang.org/tsconfig#strict for a list. "strict": true,