- TypeScript 62%
- Sass 17%
- Astro 15%
- JavaScript 3%
- GLSL 3%
doll.systems #
An Astro site for linked writing, with three reading layouts and a home page combining artwork and the site graph. HTML contains the writing, links, and navigation; JavaScript adds the graph, previews, navigation without reloads, and display effects.
The site's source code is licensed under the Mozilla Public License 2.0. Third-party code and fonts retain their existing licenses.
npm install
npm run dev
npm run lint
npm run check
npm run check:format
npm test
npm run build
For a foreground agent session, use npm run dev -- --ignore-lock. The development server runs at http://127.0.0.1:4321/.
Writing #
Add Markdown to src/content/posts/. A file named example.md becomes /example/. Dated documents appear on /latest/ and in the full-content /rss.xml feed. Undated documents are permanent pages. All documents participate in the link graph. Disposable sample pages live separately in src/content/placeholder/posts/ and category introductions in src/content/placeholder/categories/. Each uses the same schema as its authored counterpart and keeps its existing URL, links, and feed behavior.
---
title: 'Page title'
excerpt: 'A short description of this page.'
lang: en
layout: edge
---
Copy templates/page.md or templates/post.md to start a document. authors defaults to an empty list, and date is optional. Each author ID refers to a Markdown filename in src/content/authors/; unknown or duplicate IDs fail the build. Author records contain a name, excerpt, optional background image, pronouns, feature image, and online links, plus Markdown profile text; /authors/ lists them and /authors/{id}/ renders each profile. Bylines, HTML author metadata, and RSS attribution appear only when authors are supplied.
An author can add any named URL under links. Names are shown as written; names matching a glyph in src/assets/pixel-icons.json get its icon (Bluesky also matches bsky). Other names appear as text. For example:
links:
- name: Portfolio
url: https://example.com
- name: Bluesky
url: https://bsky.app/profile/example.bsky.social
If an author has a Bluesky profile link and omits featureImage, the build fetches that profile's avatar, prepares it in the site's grayscale and lavender palette without dithering, and serves it locally. An explicit featureImage keeps the chosen artwork. The prepared avatar is cached under .astro/cache/bluesky/, so a later local build can reuse it if Bluesky is unavailable; visitors never request the image from Bluesky.
The documents in src/content/placeholder/posts/ are disposable layout filler, not samhain’s authored work. Replace them before publishing. samhain’s editable profile was based on the public Bluesky bio.
title is the one title used by the page, graph, previews, feed, and social metadata. Long graph titles wrap without abbreviating the text. layout defaults to edge. Omit date for a permanent page. lang: ja sets the document language; mark mixed-language passages with lang="ja". Japanese font definitions are included when page content or linked-page metadata needs them, including on script-free visits.
Documents, author profiles, categories, and built-in pages can omit backgroundImage. Their URL path selects one of the supported images in src/assets/images/bg/generic/ with a stable hash. All current backgrounds belong to this shared pool except the homepage’s dedicated station.avif. Add an AVIF, WebP, PNG, JPEG, or GIF file there to make it available on the next build; no list needs updating. Renaming a page or changing the pool may change its selection. The finite pool can repeat across pages. To choose specific art, set backgroundImage: /images/bg/generic/your-art.avif, or use { src, exposure, focus }; focus runs from [0, 0] at the top left to [1, 1] at the bottom right. Explicit backgrounds must be unique and point to an existing file. Built-in page metadata live in src/content/pages/; adding a record makes it available to the catalog. Layouts control composition, not image choice.
Store shared background artwork in src/assets/images/bg/generic/ and reference it as /images/bg/generic/filename.avif (JPEG, WebP and PNG work too). scripts/prepare-assets.mjs discovers every background and feature image in content and page metadata automatically, before development startup and production builds. Sharp orients and resizes them and records dimensions. Backgrounds get an AVIF and a small two-tone dithered fallback still; feature images get an AVIF and a smaller preview. Only variants needed for each image’s roles are prepared and shipped. Already-sized AVIFs retain their original bytes; identical sizes share one output. Source files and pipeline settings determine the cache key, so unchanged images are not reprocessed. Generated files stay in the ignored .astro/media/ cache; the build copies only referenced images into dist/media/. No generated image manifest or JSON sidecars are needed. Source artwork stays out of the deployed site. Local images under public/ also work, but those originals will be deployed too.
To prepare a portrait or other inline image in the site's grayscale and lavender palette, run npm run image:prepare -- input.webp src/assets/images/authors/example.avif. The command orients, limits width to 960 pixels, adds a soft lavender tint to highlights, and writes AVIF without reducing tonal detail. Optional --tones 16, --tones 5, or --tones 2 enables ordered dithering; --strength adjusts it. --quality and --accent 0..1 tune compression and tint, and --accent 0 makes the image neutral. Keep the original source if you want to regenerate it later; only images referenced in content are shipped. Use the processed path as an author's featureImage.src.
featureImage is independent of the background: it appears after the title and before the body, with a slowly shifting dissolve into black at its edges, and supplies the image for graph/hover previews and social cards. Graph and hover preview images share the same edge-dissolve mask and animation as full-size feature images. Dimensions reserve its space before loading. Its required alt describes the image. Omit it for a text-only document; author profiles with a Bluesky link use its avatar instead. Optional categories is a list of labels, each with an automatically generated /categories/{slug}/ index and graph connections. Add Markdown under src/content/categories/ for an introduction or custom title. Authors, date, and categories share one metadata renderer, and absent fields are omitted.
One page catalog drives the graph, hover previews, internal-link indexes, and backlinks. It includes Home, Latest, Authors, author profiles, and all Markdown documents. Home connects to the internal pages in the shared header/footer navigation (src/lib/site-navigation.ts): About, Latest, Authors, and Privacy & acknowledgements; listing pages connect to their entries. Markdown links resolve only to catalog pages. Files, feeds, and external URLs never become graph nodes or appear in the internal-link index. Local resources use a solid underline and native browser navigation; page links use a dashed underline and external links a double underline. Document links are validated during builds. Previews use the target page’s title, excerpt, optional feature image, and metadata.
Palette lab #
/palette-lab/ is a local image workshop linked from the footer and homepage graph. Click anywhere in the upload area, drop an image, or paste one with Ctrl+V / ⌘V, then use the color slider for 8, 10, 12, 14, or 16 colors; all palette tuning and dithering are automatic. Pasting works anywhere on the page while keeping normal text entry in settings. Clicking either preview opens a full-window lightbox, closed with Close or Escape. The upload area and previews also work with the keyboard. The lightbox blocks background controls until dismissed and returns focus to the selected preview. The page uses the same reading layout, artwork, graph, and typography as the rest of the site. Swatches with hex codes appear directly below the preview, split into site and image halves. Image size and PNG/AVIF saving stay visible; AVIF quality is collapsed.
The color budget ranges from 8 to 16 in two-color steps and defaults to 12. Every setting splits evenly between the site and image. At eight colors, the site half pins exact black, ink, lavender, and muted gray. Larger budgets also include line gray, then subdivide the neutral tones for remaining slots. Images with fewer distinct colors use fewer image slots.
The palette engine builds distinct representative image colors, gently harmonizes them, and applies both palette halves in OKLab. Ordered dithering uses the same 8×8 Bayer pattern as the site artwork, mixing palette colors to represent intermediate tones. Dithering follows the selected hue rather than undoing its harmony; a perceptual penalty avoids noisy mixtures of distant colors. Exact palette matches stay clean, mapped pixels use only palette colors, and source alpha stays unchanged. Preview and export use the same pipeline and color count. A bounded color-mix cache speeds up repeated colors without retaining full-image data.
Wu partitions refined by weighted OKLab clustering create a candidate pool; observed source colors nearest the cluster centers (medoids) avoid muddy averages. Selection balances population, perceptual separation, vividness, and distance from colors already covered by the site. Analysis samples up to 16,384 pixels from an unblurred thumbnail, ignores invisible pixels, and weights partial transparency by coverage. Extraction is deterministic. The worker keeps the compact candidate pool for reuse when changing the budget and caches only the current output for saving.
Complementary harmony uses Material's HCT space, nudging colors toward the complement of the site's lavender. Rotation is bounded to 15° while preserving source tone. If a hue shift would lose more than 3% of source OKLCH chroma, its rotation is halved until it fits; it falls back to the original color when needed. This protects vivid colors at the gamut edge. Grayscale images remain grayscale.
The visible Image size slider defaults to the original image size (100%) and adjusts its longest edge in one-pixel steps. It shows the percentage and resulting dimensions and updates both previews live. Each image resets the slider to its original dimensions; images are never enlarged. The source is resized before dithering at every size. The preview retains the full output-resolution pixels and uses pixelated rendering when fitting a larger image into the available space. Compare/Result and viewport changes only change display size; they never regenerate a different dither. The lightbox’s 100% toggle shows one output pixel per CSS pixel, with scrolling for larger images, so the full dither pattern can be inspected without downsampling. PNG and AVIF exports reuse the preview’s exact dithered pixels after resizing to the chosen output dimensions. Transparency is preserved, and AVIF quality is adjustable. The export preview displays the encoded file. Source images, analysis, and output live only in page memory and are released on navigation. The browser processes and encodes images in a worker; the AVIF codec loads only when exporting AVIF. Material Color Utilities supplies Wu quantization and HCT harmonization, Culori handles perceptual conversion and gamut mapping, and jSquash supplies the single-thread AVIF codec. npm run test:palette checks representative hue coverage, vividness, actual source colors, small accents, bounded harmony, determinism, alpha, and ordered-dither tone preservation.
Algorithm references: pywal's ImageMagick extraction, Color Thief's perceptual extraction, and Material Color Utilities. The representative mode uses Wu initialization followed by deterministic OKLab refinement; it does not use Material's randomized WSmeans implementation or terminal-theme shade duplication.
Layouts and type #
ReadingContent owns page headings, metadata slots, body typography, and feature images across posts, profiles, indexes, tools, and the error page. EntryList renders latest entries, profile writing, and category writing. Author and category entries are indexed together in the catalog and reused for listing pages and graph connections.
edge: borderless reading, portrait and graph on the right.threshold: a station band above the article and graph.offset: art and graph on the left, writing on the right.
Instrument Serif is used for headings, Source Sans 3 for prose, Ilta Mono Light for inline and block code, and 12px Departure Mono for interface text. Japanese uses LINE Seed JP and native-size DotGothic16. The Ilta Mono 1.5.0 light WOFF2 is served locally and loads only where code is shown. Interface icons are native 16×16 bitmaps with one CSS pixel per cell. Their masks are embedded in the stylesheet so they need no separate loading step; their source is src/assets/pixel-icons.json. Page navigation is text-only, with accessible icon-only links for Discord and RSS.
The ⎔ identity and Japanese title flicker live in src/assets/identity.json. The header uses a 20px bitmap logo; the favicon is resized from the supplied image in src/assets/favicon.png. A large, neutral ⎔ leads the homepage title, and the artwork shader attaches another to the swaying ornament. Development startup and production builds regenerate icon masks, transition masks, the logo, favicons, and font subsets from source. These generated files stay under .astro/generated/ until the build copies serving assets into dist/. npm run assets also prepares artwork. Font and code license sources remain in public/fonts/ and public/licenses/; /licenses/INDEX.txt is generated from the notice files.
Interaction and access #
The header, main content, footer, and skip link are native HTML. The homepage combines a shortened full-width hero with the site graph and selected-page preview beginning within the first viewport. The header links to Home, About, and Latest. Authors is linked in the footer. Graph nodes are real links: Tab selects a node, Enter or Space opens its page, arrow keys move nodes, and Shift moves them farther. The current page can be dragged or nudged too, and eases back to the center on release (instantly with reduced motion). Outgoing connections from the current page have a faint lavender tint. Hover and keyboard focus highlight outgoing connections and update the preview card; the home card uses a brief ASCII dissolve with the latest selection winning. Hovering anywhere in the graph activates the artwork’s ASCII filter. A dithered radar reticle extends beyond the graph edges without clipping, and restrained red/cyan edge offsets emphasize the cursor’s surroundings; nodes and edges keep their brightness. These decorations draw only on interaction, with no idle animation. Escape dismisses link previews. Without JavaScript, reading and navigation remain available and the homepage's graph section exposes a plain list of pages.
Motion follows prefers-reduced-motion; there is no saved motion toggle. Animated buttons have a static alternative. Dust, dither, and graph geometry share a CSS-pixel grid. Graph labels retain readable native glyph rendering over sharp bitmap outlines. Pages, posts, authors (including the authors index), and categories form compact moving clusters in one shared graph. Cohesion follows each group’s current centroid, and separation keeps different groups apart without fixed regions or boundaries. Links within a group pull more strongly than links between groups. Dragging and keyboard movement can move nodes anywhere in the graph. Links, repulsion and collision shape a live D3 force layout. Overlapping labels are hidden until selected. Posts use circles, pages squares, and authors diamonds. D3 sleeps at rest and is loaded only on pages with a graph. Rendering pauses offscreen and in hidden tabs; all page listeners, observers, timers, and graphics resources are released on navigation.
Client navigation dissolves between pages through ASCII glyphs in 320ms while the header stays steady. The glyph masks are small precomputed bitmaps; the artwork also briefly uses its ASCII shader. The new page's effects mount during the DOM swap, allowing cached artwork to paint before the transition snapshot. Reduced-motion preferences disable the dissolve; ordinary links still work without JavaScript.
Artwork luminance and gamma are prepared once and cached for the two most recent images. Single-channel textures replace repeated RGB conversion in every shader frame. Hidden artwork creates graphics resources only when visible. Feature-image previews use ordinary optimized images without additional WebGL contexts. Graph labels, node sprites, and the ASCII glyph atlas reuse bounded static pixel data across navigation; interaction state and GPU resources belong to the current page. Graph highlights and the pointer reticle reuse unchanged node geometry and cached edge paths. Dev, build, and check commands use separate Vite caches, preventing builds from invalidating a running server's lazy D3 module URLs. If that module cannot load, real page links replace the empty graph.
Discovery and publishing #
src/lib/site.ts owns the site origin and indexing policy. indexing: false keeps the current draft’s HTML marked noindex and makes /robots.txt disallow crawling. Set it to true when the writing is ready. This is crawler guidance, not access control.
Every page renders canonical, description, Open Graph, and Twitter card metadata in its HTML, without JavaScript. Article metadata includes its author URL and publication date when present. /social/{page-id}.jpg is generated at build time: a 1200×630 JPEG containing the feature image, or the dithered background when there is no feature image. The image is contained without cropping and padded with black as needed. The sample Colophon demonstrates both image fields. Rich previews are metadata for sites that link here, not third-party embeds loaded by visitors. Social crawlers must be able to reach the deployed site; the current unpublished crawling policy remains in effect. Metadata follows the Open Graph protocol.
/llms.txt is generated from the document and author catalog, with titles, descriptions, bylines, and absolute URLs. It follows the llms.txt format and is advertised with rel="describedby" in page heads. It does not grant permissions or override robots.txt.
Performance and deployment #
Build output is static. Deploy dist/, enable gzip or Brotli for HTML/CSS/JavaScript/XML, and let the browser cache versioned assets. HTML whitespace is preserved for view-source. All fonts, icons, art, and 88×31 buttons are local; no page-load request goes to another site.
Hidden previews fetch their feature image only when opened. Japanese font files contain the characters used by the site and are regenerated as content changes. The title uses a separate five-glyph subset, so its occasional flicker does not download the prose font.
Development and measurement #
npm run format applies Prettier with a 100-column limit and collapses short objects where they fit; generated assets and authored content are excluded. npm run check:format checks formatting, npm run lint runs ESLint, and npm run check checks Astro and TypeScript. npm test runs every test suite; test:palette and test:graph run those suites individually. Run check and build sequentially: both update Astro’s generated content cache.
After npm run build, npm run benchmark serves the production build at http://127.0.0.1:4322/ with a local eight-second rendering sampler. It records frame intervals, animation callback CPU time, long tasks, canvas draw calls, and layout reads. Measure the same viewport and interaction on both versions, after fonts and art have loaded. The sampler runs entirely in memory and is injected by this local server; no benchmark routes, scripts, or UI are in the deployed build. It measures browser-side callbacks and frame delivery, not GPU time or network loading. Results and limitations are in PERFORMANCE.md.
Data retention #
No analytics, tracking pixels, cookies, local storage, session storage, or third-party embeds are used. Graph positions, preview selection, and effect inputs live only in page memory and are discarded on navigation or reload. Navigation uses browser history for addresses and scroll restoration. Browser caches and any hosting access logs are separate from application state. The public privacy page describes these boundaries.
/rss.xml contains complete dated posts, requires no account, and is advertised in every page's head and footer. The local 88×31 copies and their original URLs are documented in public/buttons/README.txt.
Source map #
src/lib/content.ts: author resolution, shared page catalog, internal links, backlinks, and graph data.scripts/prepare-assets.mjsandsrc/lib/images.ts: image discovery, cached build preprocessing, and typed delivery assets.scripts/asset-server.mjs: shared development asset serving and serialized regeneration when source files change.src/lib/resources.ts: resource-link classification.src/lib/metadata.ts: one metadata representation for server-rendered and interactive previews.src/components/SocialMeta.astroandsrc/pages/social/[...id].jpg.ts: static social metadata and preview images.src/lib/layouts.ts: shared layout, page-metadata, and graph types.src/content/posts/: authored writing and permanent pages.src/content/placeholder/: disposable sample writing inposts/and category introductions incategories/, with stable public URLs.src/content/pages/: discoverable metadata for built-in pages.src/layouts/: shared document and landing shells.src/components/: ordinary HTML navigation, articles, indexes, previews, and graph shell.src/scripts/page-scope.ts: page-owned events, intersection observers, and cleanup for client navigation.src/scripts/page.ts: shared lazy loading and navigation lifetime checks for page enhancements.src/scripts/webgl.ts: shader reflection, uniforms, textures, and GPU resource ownership shared by art and dust.src/scripts/graph.ts,graph-clusters.ts, andgraph-bitmap.ts: accessible graph interaction, moving category clusters, and cached pixel rendering.src/scripts/effects.ts: page-scoped display clock and effect sequencing.src/scripts/art-surface.ts,ascii-atlas.ts,dither.frag.glsl, anddisplay-noise.ts: artwork rendering, shared ASCII glyphs, shader, and dust.src/scripts/art-textures.ts,art-texture.worker.ts, andluminance.ts: bounded image cache and pixel preparation outside the rendering loop.src/lib/site.ts: site identity, absolute URLs, and crawl policy.src/lib/palette.ts,src/scripts/palette.worker.ts, andsrc/scripts/palette-tool.ts: companion palette generation, local image processing/export, and page-owned workshop controls.src/styles/main.scss: ordered style entrypoint;_tokens.scssowns palette, type, dimensions, breakpoints, layers and timing;_mixins.scssholds repeated structural rules. Feature partials keep layout-specific rules together. Generated style fragments live in.astro/generated/styles/. Component-only styles stay scoped SCSS.
Maintaining the model #
A document is Markdown with optional authors and date; dated documents are posts. A page is a catalog destination, including built-in indexes, author profiles, and categories. A page summary supplies graph, preview, and link-index content. Resources (files and feeds) remain outside that graph. catalog().context(id) returns the page, graph, and connections together so routes do not reconstruct that relationship.
Metadata items own their element type, attributes, and text; Astro and browser previews consume the same definitions. Effects own their named tuning profiles, while the shared display clock owns time and PageScope owns lifetime. Graph nodes and edges retain their derived drawing state rather than allocating parallel copies on each paint. Sass tokens and structural mixins own the shared typography, geometry, and timing; the transition duration also reaches Astro and client effects through its CSS variable.