stnd.buildSTANDARD MANUAL2026-07-15

@stnd/layout

@stnd/ayout

Definitive root layouts and base components for Standard websites.


The <html> wrapper every Standard page shares: the <head> metadata, the header/footer, the “restore theme before paint” script. Most pages just want Base.astroEncrypted.astro is the special case for a password-gated page.

---
import Base from "@stnd/layout/Base";
---
<Base title="My Page"><h1>Hello</h1></Base>

A caveat on Encrypted.astro — it’s a casual visitor gate, not real cryptographic access control: the password check is a SHA-256-derived XOR keystream, and both the ciphertext and the decryption code ship to every visitor’s browser. Predictable HTML structure (<!DOCTYPE html> at a known offset) makes a known-plaintext attack on the keystream straightforward. Fine for “keep casual visitors out”; not a substitute for real server-side auth if the content actually matters.

Overview

@stnd/layout provides the foundational structure for all websites in the Standard ecosystem. It handles essential infrastructure such as meta tags, SEO, favicons, headers, footers, and system-wide initializations.

Key Layouts

Base Layout (Base.astro)

The primary layout for most pages. It includes:

  • HTML structure with theme support.
  • Meta tags for SEO and Open Graph.
  • Site-wide Header and Footer.
  • Global styling injection.
  • System initializers (StndInit.astro).

Encrypted Layout (Encrypted.astro)

A specialized layout for password-protected content. It uses client-side encryption to ensure privacy while maintaining a consistent design.

Components

  • Header.astro: A highly configurable header with support for branding, navigation, and custom actions.
  • Footer.astro: A standard footer with links, copyright information, and slot-based extensions.
  • Meta.astro: Manages all <head> metadata, including social sharing images and Favicons.
  • StndInit.astro: Handles client-side system setup, such as theme restoration and e-ink detection.

Usage

In your Astro page:

---
import Base from "@stnd/layout/Base";
---
<Base title="My Page" description="A well-made page.">
  <h1>Hello Standard!</h1>
</Base>

Configuration

Layouts inherit site-wide settings from the config.nav and config.title defined in your module manifests or astro.config.mjs.

Future & Roadmap Layouts

These layouts are parked here for future use (Phase 4 of the Standard Roadmap).

1. Column Layout (ColumnLayout.astro — Astro Port, WIP)

A multi-column masonry-like grid layout based on BaseLayout.astro.

View ColumnLayout.astro Code
---
import BaseLayout from "./BaseLayout.astro";

export interface Props {
    title: string;
    showTitle?: boolean;
}

const { title, showTitle = true } = Astro.props;
---

<BaseLayout title={title}>
    <div class="column-layout">
        <hr />

        {
            showTitle && (
                <header class="title-row">
                    <h1>{title}</h1>
                </header>
            )
        }

        <div class="content-row">
            <div class="content-grid">
                <div class="content-section">
                    <slot />
                </div>
            </div>
        </div>
    </div>
</BaseLayout>

2. Column Template (column.njk — 11ty, Archive)

The Eleventy-era column layout using a 4-column r-grid with text-replacement helpers for styling HTML content blocks.

View column.njk Code
---
layout: default
---
{% include "partial/header.njk" %}

<r-grid columns=4>
	<hr>
	<r-cell span=row><h1>{{ page.title }}</h1></r-cell>
	<r-cell span=row></r-cell>
	<r-cell span=row flow-cols-s=1 flow-cols=2 flow-cols-l=3>
		<article style="grid-template-columns:none;">
			{% set content = content | replace("<ul>", "<ul class='tight'>") %}
			{% set content = content | replace("<h2", "</article></r-cell><r-cell span=row><h2") %}
			{% set content = content | replace("</h2>", "</h2></r-cell><r-cell span=row flow-cols-s=1 flow-cols=2 flow-cols-l=3><article style='grid-template-columns:none;'>") %}
			{{ content }}
			{% include "partial/backlinks.njk" %}
		</article>
	</r-cell>
</r-grid>
{% include "partial/footer.njk" %}

3. Encrypted Template (encrypted.njk — 11ty, Archive)

The Eleventy-era password-protected layout utilizing client-side XOR decryption and a stylized prompt, now succeeded by Encrypted.astro.

View encrypted.njk Code
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Cette page est protégée</title>
    <style>
        :root {
            --primary-color: #d78a5a;
            --error-color: #dc3545;
            --rhythm-block: #ffffff;
            --text-color: #333333;
            --color-border: #ddd;
            --font-family: InterVariable, system-ui, -apple-system, sans-serif;
        }

        * {
            box-sizing: border-box;
        }

        body {
            font-family: var(--font-family);
            margin: 0;
            padding: 40px 20px;
            background-color: var(--rhythm-block);
            color: var(--text-color);
            line-height: 1.6;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        .container {
            max-width: 400px;
            width: 100%;
            padding: 2rem;
            background: white;
        }

        h2 {
            margin: 0 0 1.5rem 0;
            text-align: center;
            color: var(--text-color);
            font-size: 1.5rem;
            font-weight: 900;
            font-feature-settings:
                "dlig", "case", "kern", "cv01", "cv02", "cv03", "cv04", "cv05", "cv06",
                "cv09", "cv10", "cv11", "cv12", "cv13";
            font-variation-settings: "";
            letter-spacing: -0.065em;
            line-height: 0.75em;
        }

        .form-group {
            margin-bottom: 1rem;
        }

        label {
            display: block;
            margin-bottom: 0.5rem;
            font-weight: 600;
            letter-spacing: -0.065em;
            color: var(--text-color);
        }

        input[type="password"] {
            width: 100%;
            padding: 12px 16px;
            border: 2px solid var(--color-border);
            border-radius: 4px;
            font-size: 1rem;
            font-family: inherit;
            font-weight: 600;
            letter-spacing: -0.065em;
            transition: border-color 0.2s ease;
            text-align:center;
        }

        input[type="password"]:focus {
            outline: none;
            border-color: var(--primary-color);
            box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1);
        }

        button {
            width: 100%;
            background: var(--primary-color);
            color: white;
            padding: 12px 24px;
            border: none;
            border-radius: 4px;
            font-size: 1rem;
            font-weight: 900;
            cursor: pointer;
            transition: background-color 0.2s ease, transform 0.1s ease;
            font-family: inherit;
        }

        button:hover {
            background: color-mix(in srgb, var(--primary-color) 40%, var(--text-color));
            transform: translateY(-1px);
        }

        button:active {
            transform: translateY(0);
        }

        button:disabled {
            opacity: 0.6;
            cursor: not-allowed;
            transform: none;
        }

        .error {
            color: var(--error-color);
            text-align: center;
            margin-top: 1rem;
            padding: 0.75rem;
            background: rgba(220, 53, 69, 0.1);
            border-radius: 4px;
            display: none;
        }

        .loading {
            display: none;
            text-align: center;
            margin-top: 1rem;
            color: var(--primary-color);
        }

        @font-face {
	font-family: InterVariable;
	font-style: normal;
	font-weight: 100 900;
	font-display: swap;
	src: url('https://rsms.me/inter/font-files/InterVariable.woff2?v=4.0') format('woff2');
          }
          @font-face {
	font-family: InterVariable;
	font-style: italic;
	font-weight: 100 900;
	font-display: swap;
	src: url('https://rsms.me/inter/font-files/InterVariable-Italic.woff2?v=4.0') format('woff2');
          }
          /* legacy name "Inter var" (Oct 2023) */
          @font-face { font-family:'Inter var'; font-style:normal; font-weight:100 900; font-display:swap; src: url('https://rsms.me/inter/font-files/InterVariable.woff2?v=4.0') format('woff2'); }
          @font-face { font-family:'Inter var'; font-style:italic; font-weight:100 900; font-display:swap; src: url('https://rsms.me/inter/font-files/InterVariable-Italic.woff2?v=4.0') format('woff2'); }
          /* static fonts */
          @font-face { font-family:Inter; font-style:normal; font-weight:100; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Thin.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:italic; font-weight:100; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-ThinItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:normal; font-weight:200; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-ExtraLight.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:italic; font-weight:200; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-ExtraLightItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:normal; font-weight:300; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Light.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:italic; font-weight:300; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-LightItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:normal; font-weight:400; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Regular.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:italic; font-weight:400; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Italic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:normal; font-weight:500; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Medium.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:italic; font-weight:500; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-MediumItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:normal; font-weight:600; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-SemiBold.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:italic; font-weight:600; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-SemiBoldItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:normal; font-weight:700; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Bold.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:italic; font-weight:700; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-BoldItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:normal; font-weight:800; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-ExtraBold.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:italic; font-weight:800; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-ExtraBoldItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:normal; font-weight:900; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Black.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:Inter; font-style:italic; font-weight:900; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-BlackItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:normal; font-weight:100; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Thin.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:italic; font-weight:100; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-ThinItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:normal; font-weight:200; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-ExtraLight.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:italic; font-weight:200; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-ExtraLightItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:normal; font-weight:300; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Light.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:italic; font-weight:300; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-LightItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:normal; font-weight:400; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Regular.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:italic; font-weight:400; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Italic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:normal; font-weight:500; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Medium.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:italic; font-weight:500; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-MediumItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:normal; font-weight:600; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-SemiBold.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:italic; font-weight:600; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-SemiBoldItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:normal; font-weight:700; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Bold.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:italic; font-weight:700; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-BoldItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:normal; font-weight:800; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-ExtraBold.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:italic; font-weight:800; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-ExtraBoldItalic.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:normal; font-weight:900; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Black.woff2?v=4.0") format("woff2"); }
          @font-face { font-family:InterDisplay; font-style:italic; font-weight:900; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-BlackItalic.woff2?v=4.0") format("woff2"); }
    </style>
</head>
<body>
    <div class="container">
        <h2>Cette page est protégée par un mot de passe</h2>

        <form id="decrypt-form">
            <div class="form-group">
                <input
                    type="password"
                    id="password"
                    placeholder="Mot de passe"
                    required
                    autocomplete="current-password"
                >
            </div>
            <button type="submit" id="unlock-btn">
                <span class="btn-text">Déverrouiller</span>
                <span class="btn-loading" style="display: none;">Déverrouillage...</span>
            </button>
        </form>

        <div id="error" class="error">Mot de passe incorrect. Veuillez réessayer.</div>
        <div id="loading" class="loading">Déchiffrement du contenu...</div>
    </div>

    <script>
        const encryptedData = "{{ encryptedData }}";
        const maxAttempts = 5;
        let attempts = 0;

        const form = document.getElementById('decrypt-form');
        const passwordInput = document.getElementById('password');
        const unlockBtn = document.getElementById('unlock-btn');
        const errorDiv = document.getElementById('error');
        const loadingDiv = document.getElementById('loading');
        const btnText = unlockBtn.querySelector('.btn-text');
        const btnLoading = unlockBtn.querySelector('.btn-loading');

        form.addEventListener('submit', async function(e) {
            e.preventDefault();

            const password = passwordInput.value;
            if (!password) return;

            await attemptDecryption(password);
        });

        function setLoading(loading) {
            unlockBtn.disabled = loading;
            btnText.style.display = loading ? 'none' : 'inline';
            btnLoading.style.display = loading ? 'inline' : 'none';
            loadingDiv.style.display = loading ? 'block' : 'none';
        }

        function showError(message) {
            errorDiv.textContent = message;
            errorDiv.style.display = 'block';

            // Add shake animation
            errorDiv.style.animation = 'shake 0.5s ease-in-out';
            setTimeout(() => {
                errorDiv.style.animation = '';
            }, 500);
        }

        function hideError() {
            errorDiv.style.display = 'none';
        }

        async function decrypt(encryptedData, password) {
            try {
                // Simple XOR decryption matching server-side implementation
                const encrypted = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0));

                // Generate key from password using SHA-256
                const encoder = new TextEncoder();
                const passwordBytes = encoder.encode(password);
                const hashBuffer = await crypto.subtle.digest('SHA-256', passwordBytes);
                const key = new Uint8Array(hashBuffer);

                // XOR decrypt
                const decrypted = new Uint8Array(encrypted.length);
                for (let i = 0; i < encrypted.length; i++) {
                    decrypted[i] = encrypted[i] ^ key[i % key.length];
                }

                const result = new TextDecoder().decode(decrypted);

                // Basic validation that decryption worked
                if (!result.includes('<!DOCTYPE') && !result.includes('<html')) {
                    throw new Error('Invalid decryption result');
                }

                return result;
            } catch (error) {
                throw new Error('Decryption failed');
            }
        }

        // Check for password in URL hash on page load
        function checkHashPassword() {
            const allowHashPassword = true;

            if (!allowHashPassword) {
                passwordInput.focus();
                return;
            }

            const hash = window.location.hash;
            if (hash && hash.length > 1) {
                const passwordFromHash = hash.substring(1); // Remove the # character
                if (passwordFromHash) {
                    // Clear the hash from URL for security (if enabled)
                    const clearHashOnLoad = true;
                    if (clearHashOnLoad) {
                        history.replaceState(null, null, window.location.pathname + window.location.search);
                    }

                    // Auto-decrypt with hash password
                    attemptDecryption(passwordFromHash);
                    return;
                }
            }

            // Auto-focus password input if no hash password
            passwordInput.focus();
        }

        async function attemptDecryption(password) {
            if (attempts >= maxAttempts) {
                showError('Too many failed attempts. Please refresh the page.');
                return;
            }

            setLoading(true);
            hideError();

            try {
                const decrypted = await decrypt(encryptedData, password);

                // Replace the current page with decrypted content
                document.open();
                document.write(decrypted);
                document.close();

            } catch (error) {
                attempts++;
                const remaining = maxAttempts - attempts;

                if (remaining > 0) {
                    showError(`Mot de passe incorrect. Veuillez réessayer. (${remaining} tentatives restantes)`);
                } else {
                    showError('Too many failed attempts. Please refresh the page.');
                    unlockBtn.disabled = true;
                }

                passwordInput.value = '';
                passwordInput.focus();
            } finally {
                setLoading(false);
            }
        }

        // Check for hash password on page load
        checkHashPassword();

        // Add CSS animation for shake effect
        const style = document.createElement('style');
        style.textContent = `
            @keyframes shake {
                0%, 100% { transform: translateX(0); }
                25% { transform: translateX(-5px); }
                75% { transform: translateX(5px); }
            }
        `;
        document.head.appendChild(style);

    </script>
</body>
</html>

Notes / Observations

  • Encrypted.astro‘s SHA-256+XOR scheme is identical to the archived
    encrypted.njk above — it wasn’t rewritten during the Astro port, weakness and all.

Todo

  • [ ] Decide Encrypted.astro’s real threat model — if it’s ever meant to
    gate something that actually matters (not just “keep casual visitors out”), the XOR keystream needs replacing with real crypto (e.g. a server-verified password, or AES-GCM with a per-page random IV) — known-plaintext against predictable HTML headers breaks the current scheme. If “casual gate” is genuinely the whole intent, just say so in the docs instead of “ensure privacy”.