hypnagaga_old/.github/copilot-instructions.md

27 KiB
Raw Blame History

Hypnagaga - Eleventy Project

This is an Eleventy (11ty) static site generator project, based on eleventy-excellent, customized for hosting music mixes with track listings and annotations.

Tech Stack: Eleventy 3.x (ESM), Tailwind CSS (token-based), CUBE CSS methodology, WebC components, Hotwired Turbo for navigation, PostCSS, esbuild

Quick Start

npm install              # Install dependencies
npm start                # Dev server at localhost:8080
npm run build            # Production build
npm run colors           # Regenerate color tokens
npm run test:a11y        # Run accessibility tests

Key Files to Know:

  • eleventy.config.js - Main Eleventy config (imports from src/_config/)
  • src/_config/ - Modular config (collections, filters, events, plugins, shortcodes)
  • src/_data/designTokens/ - Design tokens (colors, spacing, fonts)
  • src/pages/projects/mixes/ - Music mix tracks (markdown files)
  • src/assets/css/global/ - Global CSS (CUBE methodology)

Architecture & Key Concepts

Music Mix System

The project's core feature is a hierarchical music mix/track system:

  • Projects (e.g., "TomorrowsBacon") contain multiple tracks
  • Tracks are individual markdown files in src/pages/projects/mixes/{project-name}/
  • Tracks have frontmatter: project, track_number, artist, album, albumArt, start_time
  • Collection tracksByProject (defined in src/_config/collections.js) groups and sorts tracks by project
  • Layouts: mix.njk shows track list, mix-track.njk shows individual track with prev/next navigation
  • Turbo Drive provides SPA-like navigation between tracks without full page reloads

Configuration Architecture

ESM-based modular config in src/_config/ - all files use export syntax:

  • collections.js - Eleventy collections (getAllPosts, tagList, tracksByProject)
  • events.js - Build hooks (CSS/JS compilation, pagefind indexing)
  • filters.js - Template filters (dates, markdown, slugify)
  • plugins.js - Eleventy plugins (image transform, WebC, RSS, syntax highlighting)
  • shortcodes.js - Reusable template functions (image, svg, animateText)

Each category imports from subdirectories (e.g., filters/dates.js) and exports a consolidated object.

Design Tokens & Tailwind

  • Design tokens stored as JSON in src/_data/designTokens/ (colors, spacing, fonts, viewports)
  • tokensToTailwind() and clampGenerator() utilities convert tokens to Tailwind config
  • Tailwind is customized to generate utilities from design tokens (e.g., mt-xs-s, bg-pink)
  • Custom properties generated via Tailwind plugin (e.g., --color-pink, --space-m)
  • Run npm run colors after editing colorsBase.json to regenerate colors.json with shades
  • Tailwind's preflight/reset is disabled - custom reset in src/assets/css/global/reset.css

CSS Architecture (CUBE CSS)

Layer order in src/assets/css/global/global.css:

  1. tailwindcss/base (mostly unused, preflight disabled)
  2. Custom reset, fonts
  3. tailwindcss/components (where custom properties are injected)
  4. Variables, global styles
  5. Blocks, compositions, utilities (via @import-glob)
  6. tailwindcss/utilities

Key patterns:

  • Use compositions (grid, flow, wrapper) over Tailwind's container
  • Blocks for component-specific styles
  • Utilities for single-purpose helpers (e.g., text animations)
  • Custom utilities like flow-space-*, gutter-*, region-space-* control layout spacing

Build Process

Before Eleventy runs (eleventy.before event):

  1. buildAllCss() processes CSS with PostCSS (import-glob → tailwind → autoprefixer → cssnano)
    • Global CSS: src/assets/css/global/global.csssrc/_includes/css/global.css (inlined)
    • Local CSS: src/assets/css/local/**/*.csssrc/_includes/css/ (per-page bundles)
    • Component CSS: src/assets/css/components/**/*.cssdist/assets/css/components/
  2. buildAllJs() bundles JS with esbuild
    • Inline bundle: src/assets/scripts/bundle/**/*.jssrc/_includes/scripts/
    • Defer bundle: similar process
    • Component scripts: src/assets/scripts/components/**/*.jsdist/assets/components/

After Eleventy builds (eleventy.after event):

  • buildPagefind() generates search index (only in production)

Watch targets: src/assets/**/*.{css,js,svg,png,jpeg}, src/_includes/**/*.webc

Image Handling

Three methods for image optimization (via @11ty/eleventy-img):

  1. HTML Transform: Automatically processes <img> elements in HTML output
  2. Markdown: ![alt](/path/to/image.jpg) → transformed to <picture> element
  3. Shortcodes: {% image '/path/to/image.jpg', 'alt text' %} or {% imageKeys {...} %}

Images are processed to WebP/JPEG, multiple widths, and optimized. Source paths are prepended with ./src automatically in shortcodes.

Development Workflows

Essential Commands

  • npm start - Dev server with live reload (runs dev:11ty)
  • npm run build - Full production build (clean → build:11ty → build:search)
  • npm run colors - Regenerate color palettes from colorsBase.json
  • npm run favicons - Generate favicons from src/assets/svg/misc/logo.svg
  • npm run test:a11y - Run Pa11y accessibility tests

Adding a New Track

  1. Create markdown file in src/pages/projects/mixes/{project-name}/{track-number}-{slug}.md
  2. Include frontmatter:
    ---
    title: "Song Title"
    artist: "Artist Name"
    album: "Album Name"
    project: ProjectName  # must match existing project
    track_number: 1
    albumArt: "/pages/projects/mixes/{project}/image.jpg"
    ---
    
  3. Track automatically appears in collections.tracksByProject[project]
  4. Navigation (prev/next) auto-generates in mix-track.njk layout

Working with Design Tokens

  1. Edit JSON files in src/_data/designTokens/
  2. For colors: Edit colorsBase.json → run npm run colors → regenerate colors.json
  3. Tokens flow to: CSS custom properties, Tailwind utilities, and templates (via {{ designTokens }})
  4. Fluid type/space uses Utopia-style clamps (configured in clamp-generator.js)

Text Animations

Use the animateText shortcode or markdown-it-attrs classes:

{% animateText "Hello", "shiver", "1.5" %}  {# content, animation, speed #}

Available: shiver, wobble, jitter, bounce, bob, wave (defined in src/assets/css/global/utilities/text-animations.css) Safelist these classes in tailwind.config.js so they're not purged.

Project-Specific Patterns

Collections

  • allPosts: All markdown files in src/posts/**/*.md (reversed chronological)
  • tagList: Unique tags across all content (excludes 'posts', 'docs', 'all', 'mix', 'project')
  • goPages: Pages with go: frontmatter for URL redirects (output to _redirects)
  • tracksByProject: Tracks grouped by project, sorted by track_number

WebC Components

Located in src/_includes/webc/:

  • <custom-card> - Card layout with slots (image, headline, date, tag, content, footer)
  • <custom-masonry> - Masonry grid (with optional layout="50-50")
  • <custom-details> - Enhanced <details> with expand/collapse all buttons

Turbo Drive Navigation

  • Enabled for track-to-track navigation (no full page reload)
  • <turbo-frame id="main-content"> in layouts wraps main content
  • Import Turbo in defer bundle: {% js "defer" %} import * as Turbo from '/assets/components/turbo.js'; {% endjs %}

Pagination

Defined in src/pages/blog.njk with pagination.size. Adjust labels in src/_data/meta.js (blog.paginationLabel, etc.).

Go URLs / Redirects

Add go: /short-url to page frontmatter. The goPages collection generates _redirects file for Netlify/Vercel.

Common Issues & Solutions

CSS Not Updating

CSS is built in eleventy.before event, not by Eleventy's passthrough copy. Changes to src/assets/css/**/*.css trigger rebuild via watch targets.

Images Not Processing

Ensure image paths start with / (relative to src/). HTML Transform plugin processes existing <img> tags in output HTML.

New Color Not Available

Run npm run colors after editing colorsBase.json. Update src/assets/css/global/base/variables.css if color names change.

Tailwind Class Not Generated

Check if the utility is enabled in tailwind.config.js theme (only backgroundColor, textColor, margin, padding enabled by default). Add more if needed.

Track Navigation Broken

Verify project frontmatter matches across tracks. Collection sorts by track_number (ensure it's a number, not string).

File Conventions

This project uses ESM syntax instead of CommonJS. Configurations are structured into separate modules in src/_config and are then imported into the main configuration file.

Each configuration category (filters, plugins, shortcodes, etc.) is modularized. or example, dates.js within the filters folder contains date-related filters.

import dayjs from 'dayjs';

export const toISOString = dateString => dayjs(dateString).toISOString();
export const formatDate = (date, format) => dayjs(date).format(format);

These individual modules are then imported and consolidated in a central filters.js file, which exports all the filters as a single default object.

import {toISOString, formatDate} from './filters/dates.js';
// more imports

export default {
  toISOString,
  formatDate,
// more exports
};

Integration in Eleventy Config

In the main Eleventy configuration file (eleventy.config.js), these modules are imported:

import filters from './src/_config/filters.js';
import shortcodes from './src/_config/shortcodes.js';

They are then used to register filters and shortcodes with Eleventy, using this nice concise syntax:

eleventyConfig.addFilter('toIsoString', filters.toISOString);
eleventyConfig.addFilter('formatDate', filters.formatDate);
// More filters...
eleventyConfig.addShortcode('svg', shortcodes.svgShortcode);

This method hopefully keeps the Eleventy config clean and focused, only concerning itself with the registration of functionalities, while the logic and definition remain abstracted in their respective modules.

CSS

We are using Tailwind CSS to generate utility classes on demand, based on our design tokens. Add and delete your globally available custom block stylesheets in src/assets/css/global/blocks/*.css.

The methodology used is CUBE CSS.

If you have a look at the tailwind.config.js, you can see how that is done. For example, we are deactivating Tailwinds default reset.

We are hooking into the components layer, to make Tailwind output classes based on our tokens, instead of their default design system.

That is, you are able to use mt-xs-s instead of a class like mt-20 for example. Same goes for colors, depending on the namesin your colors.json, you get custom classes like text-pink. These use the usual Tailwind prefixes (see docs to learn how to generate colors).

You get a custom property mapped to the color name --color-my-custom-color-name and the classes bg-my-custom-color-name as well as text-my-custom-color-name.

Consider that we limit those utilities in the theme section:

backgroundColor: ({theme}) => theme('colors'),
textColor: ({theme}) => theme('colors'),
margin: ({theme}) => ({ auto: 'auto', ...theme('spacing')}),
padding: ({theme}) => theme('spacing')

If you want to add the generation for border-color classes for example, youd have to add that right there:

{% raw %}

borderWidth: ({theme}) => theme('borderWidth'),
borderColor: ({theme}) => theme('colors')

{% endraw %}

Also. you do have something like md:text-right available because we define the screens (src/_data/designTokens/viewports.json):

{% raw %}

screens: {
  ltsm: {max: `${viewportTokens.sm}px`},
  sm: `${viewportTokens.sm}px`,
  md: `${viewportTokens.md}px`,
  navigation: `${viewportTokens.navigation}px`
},

{% endraw %}

Additionally, you get custom properties based on the naming of your design token files:

{% raw %}

const groups = [
  {key: 'colors', prefix: 'color'},
  {key: 'borderRadius', prefix: 'border-radius'},
  {key: 'spacing', prefix: 'space'},
  {key: 'fontSize', prefix: 'size'},
  {key: 'lineHeight', prefix: 'leading'},
  {key: 'fontFamily', prefix: 'font'},
  {key: 'fontWeight', prefix: 'font'}
];

{% endraw %}

The clampGenerator generates Utopia-like CSS clamp values for fluid type and space and the tokensToTailwind function converts whatever format the projects design tokens are in, into Tailwind friendly configuration objects.

module.export

We dont let Tailwind create all the utilities that it can do at the core. Each of the single keyword properties like fontSize are defined at the top of the file by running through the tokensToTailwind function, so they get referenced straight in the config.

The Tailwind media query function — screen — is very rarely used, but we at least want that rigged up to design tokens.

// Prevents Tailwind's core components
blocklist: ['container'],

Container is not needed because theres a wrapper in css/compositions.


experimental: {
  optimizeUniversalDefaults: true
},

This config value also contributes to getting rid of the massive wall of Custom Properties.

plugins

{% raw %}


plugin(function ({addComponents, config}) {
  let result = '';

  const currentConfig = config();

  const groups = [
    {key: 'colors', prefix: 'color'},
    {key: 'spacing', prefix: 'space'},
    {key: 'fontSize', prefix: 'size'},
    {key: 'fontLeading', prefix: 'leading'},
    {key: 'fontFamily', prefix: 'font'},
    {key: 'fontWeight', prefix: 'font'}
  ];

  groups.forEach(({key, prefix}) => {
    const group = currentConfig.theme[key];

    if (!group) {
      return;
    }

    Object.keys(group).forEach(key => {
      result += `--${prefix}-${key}: ${group[key]};`;
    });
  });

  addComponents({
    ':root': postcssJs.objectify(postcss.parse(result))
  });
}),

{% endraw %}

Right, this is the sort of thing that sold me on Tailwind. They have a whole custom plugin system that you can tap into. One thing I want to always do is generate a nice block of Custom Properties, based on design tokens. Thats exactly what the above code does.

It goes through each defined group (groups) and then grabs the values > generates Custom Property values > sticks them to the result.

Finally, using postcssJs and postcss, an object that Tailwind can understand is created. The addComponents function sticks that custom properties block on the @components layer. Its a bit of a hack, but it does the job.

{% raw %}


plugin(function ({addUtilities, config}) {
  const currentConfig = config();
  const customUtilities = [
    {key: 'spacing', prefix: 'flow-space', property: '--flow-space'},
    {key: 'spacing', prefix: 'region-space', property: '--region-space'},
    {key: 'spacing', prefix: 'gutter', property: '--gutter'}
  ];

  customUtilities.forEach(({key, prefix, property}) => {
    const group = currentConfig.theme[key];

    if (!group) {
      return;
    }

    Object.keys(group).forEach(key => {
      addUtilities({
        [`.${prefix}-${key}`]: postcssJs.objectify(
          postcss.parse(`${property}: ${group[key]}`)
        )
      });
    });
  });
})

{% endraw %}

This function creates custom utilities that can be used in markup, such as gutter-m or flow-space-s. Its all rigged up to the design tokens Custom Property block. Its damn useful, especially when tweaking layout compositions in context.

PostCSS

{% raw %}


@import 'tailwindcss/base';

@import 'global/reset.css';
@import 'global/fonts.css';

@import 'tailwindcss/components';

@import 'global/variables.css';
@import 'global/global-styles.css';

@import-glob 'blocks/*.css';
@import-glob 'compositions/*.css';
@import-glob 'utilities/*.css';

@import 'tailwindcss/utilities';

{% endraw %} I try to maintain a decent source order for specificity purposes as you can see. The @import 'tailwindcss/components' is where that block of Custom Properties generated in the Tailwind config gets put. Because everything else that layer does is disabled in config, its nice and clean.

The CUBE parts are all imported using the extremely useful import-glob PostCSS plugin. This allows new files to be added to directories and imported straight away.

Inline CSS and bundles

The main CSS file is now inline in production to improve performance, see .src/_includes/head/css-inline.njk.

You can add per-page or component bundles of CSS. Instead of adding your CSS file to the src/assets/css/global/blocks/ directory, you can place them in src/assets/css/local/. All CSS files in there will be stored alongside global.css in .src/_includes/css/. You can now include them in the "local" bundle only on pages or components where you need them:

{% raw %}

{% css "local" %}
  {% include "css/your-stylesheet.css" %}
{% endcss %}

{% endraw %}

Component CSS

All CSS files placed in src/assets/css/components/ will be sent to the output folder, where components can reference them: /assets/css/components/*.css.

Debugging CSS

In src/assets/css/global.css you can decomment @import-glob 'tests/*.css'; to include CSS for debugging.

It makes visible when your code wrapped in <is-land> elements is being hydrated, where things might overflow and many other warnings and errors that Heydon Pickering came up with.

Cascade layers

We now use cascade layers! Up until now, I used the :where() pseudo-class to create low specificity selectors for the reset and compositions. Mayank inspired me to change to cascade layers. We have two major bundles of CSS: everything included in "global" In src/assets/css/global/global.css is now organized in cascade layers. The "local" bundle is for per-page or component CSS, and does not use cascade layers - it has thus a higher specificity, independent of any selector specificity in the global CSS.

Design Tokens

Edit all your preferences (colors, fluid text sizes etc.) in src/_data/designTokens/*.json.

Additional colors, variants and gradients for custom properties are automatically created in src/assets/css/global/base/variables.css based on the colors set in colors.json.

In the style guide you can see how everything turns out.

Special case: colors

As of version 4.0, you can create colors dynamically. Run npm run colors after setting your color values in src/_data/designTokens/colorsBase.json. This will create / overwrite the required colors.json file in the same directory. These colors become custom properties (e.g. --color-gray-100) and utility classes similar to the Tailwind CSS syntax (for example bg-gray-100, text-gray-900).

If you want to adjust how the colors turn out, edit src/_config/setup/create-colors.js.

Colors placed under shades_neutral or shades_vibrant are converted into scalable palettes. shades_neutral is better for grayish / monochromatic colors, while shades_vibrant is better for colorful palettes. Colors listed under standalone and light_dark are left as they are, light_dark items output a second "subdued" version optimized for dark themes.

// this creates a palette with shades of green, 100 to 900
  "shades_vibrant": [
    {
      "name": "green",
      "value": "#008000"
    }
  ],

Important: If you change the color names, you must edit src/assets/css/global/base/variables.css with your color names. The rest of the CSS files should only reference custom properties set in variables.css.

Javascript

This project uses ESM syntax instead of CommonJS. Configurations are structured into separate modules in src/_config and are then imported into the main configuration file.

There are two kinds of bundles for JavaScript in this starter, see .src/_includes/head/js-inline.njk and .src/_includes/head/js-defer.njk. By default, I include Eleventy's is-land framework and the theme toggle inline.

You can include more scripts like so:

{% raw %}

{% js "inline" %}
 {% include "scripts/your-inline-script.js" %}
{% endjs %}

{% endraw %}

Same goes for scripts that should be defered:

{% raw %}

{% js "defer" %}
 {% include "scripts/your-defered-script.js" %}
{% endjs %}

{% endraw %}

Scripts stored in src/assets/scripts/components/ are sent to the output folder, while scripts in src/assets/scripts/bundle/ are sent to .src/_includes/scripts/, from where you can include them in the respective bundle.

Details

The <custom-details> WebC component has a corresponding Nunjucks include. It uses the <details> and <summary> elements to create a collapsible section and enhances them aesthetically and functionally.

The JavaScript for the <custom-details> component adds functionality to buttons to expand and collapse the sections with one action. When JavaScript is disabled, the sections are still accessible and collapsible, but the extra buttons are hidden.

On page load, it checks if a hash corresponding to a details ID exists in the URL. If such an ID is found, the corresponding details section is programmatically opened, allowing direct navigation to an open section from a shared URL.

The sorting is set by default on "alphabetic", but you can also pass in "shuffle" or "reverse" as a parameter (directly in the details.njk partial).

Usage

{% raw %}

{% set itemList = collections.docs %}
{% set headingLevel = "h2" %} {# optional, defaults to false #}
{% include 'partials/details.njk' %}

{% endraw %}

Images

Using the Eleventy Image plugin, there are three ways to handle image optimization: HTML Transform, Markdown syntax, and Nunjucks shortcodes. See the dedicated blog post to dive (much) deeper.

Have a look at the Attribute Overrides for the HTML Transform methods (1 and 2) for per instance overrides. Adding eleventy:ignore to an <img> element for example, skips this image.

1. HTML Transform

The HTML Transform automatically processes <img> and <picture> elements in your HTML files as a post-processing step during the build.

<img src="./path/to/image.jpg" alt="alt text">

2. Markdown Syntax

The Markdown syntax creates the <img> element that the HTML Transform plugin is looking for, and then transforms it to the <picture> element (if more than one format is set).

![alt text](/path/to/image.jpg)

3. Nunjucks Shortcodes

In Nunjucks templates you can also use shortcodes (image and imageKeys).

{% raw %}

{% image '/path/to/image.jpg', 'alt text' %}
{% imageKeys {
  "alt": "alt text",
  "src": "/path/to/image.jpg"
} %}

{% endraw %}

Custom masonry

<custom-masonry> is designed to function as a masonry grid by dynamically adjusting item positions based on the available column space and the size of its content. The necessary JavaScript (custom-masonry.js) is loaded only once per component usage due to the data-island="once" attribute. Optional: pass in layout="50-50" to set a 50% width for each column.

If no JavaScript is available, the grid will fall back to the regular grid layout defined in src/assets/css/global/compositions/grid.css.

<custom-masonry> (children) </custom-masonry>
<custom-masonry layout="50-50"> (children) </custom-masonry>

Navigation

Edit your navigation items in src/_data/navigation.js.

You have two options for mobile navigation: by default, the navigation on small displays is converted to small pills that wrap. This does not require any additional JavaScript.

Drawer Menu

You can activate a drawer menu for mobile in src/_data/meta.js:

navigation: {
   // other settings
    drawerNav: true,
  },

drawerNav activates the navigation drawer, built according to Manuel Matuzović's article on web.dev.

Adjust your menu breakpoint in src/_data/designTokens/viewports.json

{
 // ...
  "navigation": 662,
 // ...
}

Submenu

You can activate submenus in src/_data/meta.js:

navigation: {
   // other settings
    subMenu: true,
  },

This includes the JavaScript for the submenu functionality. Add your submenu items to src/_data/navigation.js using this structure:

 {
  text: 'Unlinked parent',
  url: '#',
  submenu: [
    {
      text: 'Sub Item',
      url: '/sub-item/'
    },
    ... more items
  ]
},

Pagination

The blog posts use Eleventy's pagination feature. The logic for this can be found in tha partial src/_includes/partials/pagination.njk, the layout src/_layouts/blog.njk includes it, how many entries should be on a page is defined in src/pages/blog.md.

If you do not want any pagination at all, it is easiest to set a very high number for the pagination size, for example:

pagination:
  data: collections.posts
  size: 10000

In src/_data_/meta.js you can set some values for the visible content (previous / next buttons) and the aria labels.

You can also hide the number fields between the previous and next buttons by setting paginationNumbers to false.

blog: {
	// other adjustments
	paginationLabel: 'Blog',
	paginationPage: 'Page',
	paginationPrevious: 'Previous',
	paginationNext: 'Next',
	paginationNumbers: true
}

If you want to change the collection that is paginated (by default collections.posts), you must do so in two places: the front matter of the template, src/pages/blog.md:

pagination:
  data: collections.posts

and where the pagination component is included: src/_layouts/blog.njk:

{% raw %}

<!-- set collection to paginate -->
{% set collectionToPaginate = collections.posts %}
<!-- if the number of items in the collection is greater than the number of items shown on one page -->
{% if collectionToPaginate.length > pagination.size %}
<!-- include pagination -->
{% include 'partials/pagination.njk' %}
{% endif %}

{% endraw %}

Cards

WebC, we can use the custom element and opt in to the different slots. Available slots:

image: has slot="image" on the container (picture or figure) by default)
headline: display the card's main title
date and tag: Grouped within the classes meta and cluster for date and tagging information
content
footer: for links or whatever footer information

I added some variants, avaliable via attribute selectors:

img-square: Enforces a square aspect ratio for images
clickable: Makes the whole card clickable
no-padding: Removes padding and background modifications

Usage

{% raw %}

<custom-card>
{% image "path-to-img", "alt-text" %}
<span slot="date"></span>
<span slot="tag" class="button"></span>
<h2 slot="headline"></h2>
<p slot="content"></p>
<footer slot="footer"></footer>
</custom-card>

{% endraw %}