From aa9ea34683a86335f5c5a9f7b5481c3f22dede16 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 06:22:06 +0000 Subject: [PATCH 01/19] Enforce two-branch policy in CLAUDE.md: main + development only Non-canonical branches must be deleted immediately after merging. https://claude.ai/code/session_01R4b6mihSGtCUzyW4Sd5jzM --- CLAUDE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 50c27bc..1e8a99f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,10 +8,12 @@ Every merge into `main` is a release. Before committing any change to `mdcms.py` ## Branching convention +Only two branches exist in this repository: **`main`** and **`development`**. No other branches should be created or left alive. + - **`main`** is the release branch. Every merge to `main` is a release. Never commit work-in-progress directly to `main`. -- **`development`** is the default branch for all development, including all Claude-driven work. Create it from `main` if it doesn't exist. Do not create a new branch per conversation. -- **Phased branches** (`claude/`) are allowed when a large feature needs staged review, but the final merge target is always `main` via `development`. +- **`development`** is the default branch for all development, including all Claude-driven work. Always commit to `development` — never create a new branch per conversation or feature. - **Documentation only** (`CLAUDE.md`, `docs/`) — may be pushed directly to `main`. +- **If a non-canonical branch is created** (e.g. for a large staged feature), it must be deleted immediately after it is merged. The repo returns to `main` + `development` only. In practice: check out `development`, do the work, push to `development`, PR `development` → `main` when ready to release. From e1527d8e3b9c1a0bd85d760b3c2224ca43062555 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 06:52:43 +0000 Subject: [PATCH 02/19] Show category-less posts/pages in all categories Files without a category suffix (e.g. post.md alongside post.en.md) previously only appeared in the default category. They now appear in every category, so untranslated content is always visible. - mdcms.py: nav entries with a bare variant get `uncategorized: true`; search.json keeps `category: null` instead of mapping to default code - index.html: pageShouldDisplay, posts filter, and category dropdown all treat uncategorized/null-category items as universally visible https://claude.ai/code/session_01EzU13EL8D5Ud2ngQUKDj9e --- app/index.html | 7 ++++--- mdcms.py | 15 ++++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/index.html b/app/index.html index c813133..656fe37 100644 --- a/app/index.html +++ b/app/index.html @@ -1200,6 +1200,7 @@ body { // - Otherwise: hide if (!categoriesUse) return true; if (page.file === defaultPage()) return true; + if (page.uncategorized) return true; const variants = page.variants || []; if (variants.includes(activeCategory)) return true; const cat = categoriesByCode[activeCategory]; @@ -1602,7 +1603,7 @@ function fmtDatetime(dtStr) { // Category filter if (categoriesUse && activeCategory) { - posts = posts.filter(function(e) { return e.category === activeCategory; }); + posts = posts.filter(function(e) { return !e.category || e.category === activeCategory; }); } // Field filter @@ -2245,7 +2246,7 @@ function fmtDatetime(dtStr) { ? navData.find(p => p.file === currentPage) : null; categoriesList.forEach(cat => { - const hasVariant = !page || !page.variants || page.variants.includes(cat.code); + const hasVariant = !page || page.uncategorized || !(page.variants && page.variants.length) || page.variants.includes(cat.code); const hasMsg = !!cat.notfoundmessage; if (hasVariant || hasMsg || cat.code === activeCategory) out.add(cat.code); }); @@ -2272,7 +2273,7 @@ function fmtDatetime(dtStr) { 'data-code': cat.code }); option.appendChild(document.createTextNode(primary)); - const hasVariant = !page || !page.variants || page.variants.includes(cat.code); + const hasVariant = !page || page.uncategorized || !(page.variants && page.variants.length) || page.variants.includes(cat.code); if (!hasVariant && cat.notfoundmessage) { option.appendChild(el('span', { className: 'secondary', textContent: cat.notfoundmessage })); } else if (secondary) { diff --git a/mdcms.py b/mdcms.py index 7050c9e..b50ebb5 100644 --- a/mdcms.py +++ b/mdcms.py @@ -266,10 +266,14 @@ def build_page_nav( } if categories_use: covered = {} + has_uncategorized = False for code, record in variants.items(): - key = code if code is not None else default_code - if key: - covered[key] = record.get("title", "") + if code is None: + has_uncategorized = True + else: + covered[code] = record.get("title", "") + if has_uncategorized: + entry["uncategorized"] = True entry["variants"] = sorted(covered.keys()) entry["titles"] = covered out.append(entry) @@ -313,6 +317,8 @@ def generate_nav_yml(sections: list, pages: list, categories_use: bool = False) if p.get("section-id"): lines.append(f" section-id: {p['section-id']}") lines.append(f" sort: {p.get('sort', 100)}") + if categories_use and p.get("uncategorized"): + lines.append(" uncategorized: true") if categories_use and p.get("variants"): lines.append(f" variants: [{', '.join(p['variants'])}]") if categories_use and p.get("titles"): @@ -344,8 +350,7 @@ def generate_search_json( "body": r.get("body", ""), } if categories_use: - code = r.get("code") - entry["category"] = code if code is not None else default_code + entry["category"] = r.get("code") # None → null = show in all categories out.append(entry) return json.dumps(out, indent=2, ensure_ascii=False) From 1279b8035d9773c6cd5ec5347b9747ecdc4ec4c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 06:57:12 +0000 Subject: [PATCH 03/19] Add unreleased.md documenting development-only changes https://claude.ai/code/session_01EzU13EL8D5Ud2ngQUKDj9e --- docs/unreleased.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/unreleased.md diff --git a/docs/unreleased.md b/docs/unreleased.md new file mode 100644 index 0000000..ee88ec0 --- /dev/null +++ b/docs/unreleased.md @@ -0,0 +1,34 @@ +# Unreleased changes + +Changes merged into `development` that have not yet been released to `main`. + +--- + +## Untranslated content now visible in all categories + +**Status:** On `development`, pending release. + +### What was broken + +When the category system is enabled, a page or post file without a category suffix (e.g. `posts/my-post.md`) was silently assigned to the default category only. Switching to any other category caused those files to disappear from the navigation and from `posts-*` tag listings — even though no translated version existed. If you wrote English posts without a `.en.md` suffix, they simply vanished the moment a visitor switched language. + +### What it does now + +Files without a category suffix are treated as uncategorised — meaning they belong to every category, not just the default one. A post called `my-post.md` now appears in the nav and post lists regardless of which category is active. A post called `my-post.en.md` still appears only in the `en` category as before. + +Mixed situations work as expected: if you have both `my-post.md` and `my-post.nb.md`, the Norwegian variant is shown when the `nb` category is active, and the bare `my-post.md` is shown for every other category. + +### What changes in the build output + +After rebuilding a site with `mdcms build`, affected entries in `nav.yml` gain an `uncategorized: true` field: + +```yaml +- file: posts/my-post.md + title: My Post + sort: 100 + uncategorized: true +``` + +In `search.json`, these entries carry `"category": null` instead of the default category code. This is what tells the renderer to include them universally. + +A rebuild is required for existing sites to pick up the change. From 11dc053118c736d2ccdb4b984c96f952563f5499 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 07:00:36 +0000 Subject: [PATCH 04/19] Scope uncategorized-post fix to posts/ only Pages without a category suffix still map to the default category. Only posts/ files without a suffix get uncategorized: true in nav.yml and category: null in search.json. https://claude.ai/code/session_01EzU13EL8D5Ud2ngQUKDj9e --- docs/unreleased.md | 10 ++++++---- mdcms.py | 15 +++++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/unreleased.md b/docs/unreleased.md index ee88ec0..da789a7 100644 --- a/docs/unreleased.md +++ b/docs/unreleased.md @@ -4,23 +4,25 @@ Changes merged into `development` that have not yet been released to `main`. --- -## Untranslated content now visible in all categories +## Untranslated posts now visible in all categories **Status:** On `development`, pending release. ### What was broken -When the category system is enabled, a page or post file without a category suffix (e.g. `posts/my-post.md`) was silently assigned to the default category only. Switching to any other category caused those files to disappear from the navigation and from `posts-*` tag listings — even though no translated version existed. If you wrote English posts without a `.en.md` suffix, they simply vanished the moment a visitor switched language. +When the category system is enabled, a post file without a category suffix (e.g. `posts/my-post.md`) was silently assigned to the default category only. Switching to any other category caused those posts to disappear from the nav and from `posts-*` tag listings — even though no translated version existed. If you wrote posts without a language suffix, they simply vanished the moment a visitor switched category. + +Pages without a category suffix are unaffected: they continue to be assigned to the default category, which is the correct behaviour for pages. ### What it does now -Files without a category suffix are treated as uncategorised — meaning they belong to every category, not just the default one. A post called `my-post.md` now appears in the nav and post lists regardless of which category is active. A post called `my-post.en.md` still appears only in the `en` category as before. +Posts without a category suffix are treated as uncategorised — meaning they appear in every category. A post called `my-post.md` now shows up regardless of which category is active. A post called `my-post.en.md` still appears only in the `en` category as before. Mixed situations work as expected: if you have both `my-post.md` and `my-post.nb.md`, the Norwegian variant is shown when the `nb` category is active, and the bare `my-post.md` is shown for every other category. ### What changes in the build output -After rebuilding a site with `mdcms build`, affected entries in `nav.yml` gain an `uncategorized: true` field: +After rebuilding a site with `mdcms build`, affected post entries in `nav.yml` gain an `uncategorized: true` field: ```yaml - file: posts/my-post.md diff --git a/mdcms.py b/mdcms.py index b50ebb5..8a90c69 100644 --- a/mdcms.py +++ b/mdcms.py @@ -265,11 +265,15 @@ def build_page_nav( "sort": sort, } if categories_use: + is_post = file.startswith("posts/") covered = {} has_uncategorized = False for code, record in variants.items(): if code is None: - has_uncategorized = True + if is_post: + has_uncategorized = True + elif default_code: + covered[default_code] = record.get("title", "") else: covered[code] = record.get("title", "") if has_uncategorized: @@ -350,7 +354,14 @@ def generate_search_json( "body": r.get("body", ""), } if categories_use: - entry["category"] = r.get("code") # None → null = show in all categories + code = r.get("code") + is_post = r.get("file", "").startswith("posts/") + if code is not None: + entry["category"] = code + elif is_post: + entry["category"] = None # null = show in all categories + else: + entry["category"] = default_code out.append(entry) return json.dumps(out, indent=2, ensure_ascii=False) From 269980ea28e82fc1119ee271fe9205e9bb1cd33a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 08:27:53 +0000 Subject: [PATCH 05/19] Set page title from config.sitename instead of hardcoded MD-CMS The static element previously showed "MD-CMS" before JavaScript loaded. The JS already sets document.title from config.sitename on boot, so clearing the initial value ensures the browser tab never displays the hardcoded string. --- app/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/index.html b/app/index.html index d811dc9..2987482 100644 --- a/app/index.html +++ b/app/index.html @@ -21,7 +21,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> -<title>MD-CMS + From ee3d4872a0680c16c0ded74203f8990bead93a93 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 08:31:47 +0000 Subject: [PATCH 06/19] Patch in index.html with sitename during build mdcms build now writes the sitename from config.yml into the <title> tag of index.html. WhatsApp, Slack, and other link-preview crawlers read the static HTML without executing JavaScript, so the title must be correct in the raw file. Previously it was blank (or "MD-CMS" in older templates). --- docs/unreleased.md | 10 ++++++++++ mdcms.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 docs/unreleased.md diff --git a/docs/unreleased.md b/docs/unreleased.md new file mode 100644 index 0000000..ecafdfd --- /dev/null +++ b/docs/unreleased.md @@ -0,0 +1,10 @@ +# Unreleased changes + +Changes on `development` not yet merged to `main`. + +## Fixes + +- **`mdcms build` now patches `<title>` in `index.html` with `sitename` from `config.yml`.** + Previously the HTML `<title>` tag was a hardcoded blank (or "MD-CMS" in older templates), + so link previews in apps like WhatsApp showed the wrong name. After each build the tag + reflects the configured site name, which crawlers and preview scrapers read directly. diff --git a/mdcms.py b/mdcms.py index 7050c9e..6ca43cd 100644 --- a/mdcms.py +++ b/mdcms.py @@ -412,6 +412,19 @@ def validate_assets(site_path: Path, cfg: dict) -> list: # ─── Core build logic ───────────────────────────────────────── +_TITLE_RE = re.compile(r"<title>[^<]*") + + +def _patch_html_title(site_path: Path, sitename: str) -> None: + index = site_path / "index.html" + if not index.exists(): + return + html = index.read_text(encoding="utf-8") + new_html = _TITLE_RE.sub(f"{sitename}", html, count=1) + if new_html != html: + index.write_text(new_html, encoding="utf-8") + + def run_build(site_path: Path): """Scan pages/ and posts/, write nav.yml and search.json. Raises ClickException on failure.""" if not site_path.is_dir(): @@ -491,6 +504,8 @@ def run_build(site_path: Path): ) click.echo(f" Wrote search.json ({len(live_pages) + len(post_records)} entries)") + _patch_html_title(site_path, cfg.get("sitename", "")) + pwa_enabled = str(cfg.get("pwa", "no")).lower() in ("yes", "true") if pwa_enabled: generate_pwa(site_path, cfg) From a8fcc79ba90793f01f76c70f5031ff8c69fa434c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 08:27:53 +0000 Subject: [PATCH 07/19] Set page title from config.sitename instead of hardcoded MD-CMS The static element previously showed "MD-CMS" before JavaScript loaded. The JS already sets document.title from config.sitename on boot, so clearing the initial value ensures the browser tab never displays the hardcoded string. --- app/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/index.html b/app/index.html index 49d5f67..93fcc5d 100644 --- a/app/index.html +++ b/app/index.html @@ -21,7 +21,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> -<title>MD-CMS + From 28b248735feb3515d2bee2dd3706ac73e70fedbc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 08:31:47 +0000 Subject: [PATCH 08/19] Patch in index.html with sitename during build mdcms build now writes the sitename from config.yml into the <title> tag of index.html. WhatsApp, Slack, and other link-preview crawlers read the static HTML without executing JavaScript, so the title must be correct in the raw file. Previously it was blank (or "MD-CMS" in older templates). --- docs/unreleased.md | 6 ++++++ mdcms.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/unreleased.md b/docs/unreleased.md index da789a7..3068e82 100644 --- a/docs/unreleased.md +++ b/docs/unreleased.md @@ -4,6 +4,12 @@ Changes merged into `development` that have not yet been released to `main`. --- +## `mdcms build` patches `<title>` with sitename + +`mdcms build` now rewrites the `<title>` tag in `index.html` with the value of `sitename` from `config.yml`. Previously the tag was hardcoded (`MD-CMS`) in older templates, or blank in the starter template, so link previews in WhatsApp, Slack, and other crawlers that read static HTML showed the wrong name. + +--- + ## Untranslated posts now visible in all categories **Status:** On `development`, pending release. diff --git a/mdcms.py b/mdcms.py index 8a90c69..10229f0 100644 --- a/mdcms.py +++ b/mdcms.py @@ -428,6 +428,19 @@ def validate_assets(site_path: Path, cfg: dict) -> list: # ─── Core build logic ───────────────────────────────────────── +_TITLE_RE = re.compile(r"<title>[^<]*") + + +def _patch_html_title(site_path: Path, sitename: str) -> None: + index = site_path / "index.html" + if not index.exists(): + return + html = index.read_text(encoding="utf-8") + new_html = _TITLE_RE.sub(f"{sitename}", html, count=1) + if new_html != html: + index.write_text(new_html, encoding="utf-8") + + def run_build(site_path: Path): """Scan pages/ and posts/, write nav.yml and search.json. Raises ClickException on failure.""" if not site_path.is_dir(): @@ -507,6 +520,8 @@ def run_build(site_path: Path): ) click.echo(f" Wrote search.json ({len(live_pages) + len(post_records)} entries)") + _patch_html_title(site_path, cfg.get("sitename", "")) + pwa_enabled = str(cfg.get("pwa", "no")).lower() in ("yes", "true") if pwa_enabled: generate_pwa(site_path, cfg) From 7b2d54da573229c002b5bda0ad623cbe43d4d54c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 08:38:19 +0000 Subject: [PATCH 09/19] Update CLAUDE.md: document patching in run_build --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5f999ff..90afbd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,7 +99,7 @@ Single-module Python script. Logical layers in order: 5. **Category system** — `identify_variant()` splits `.md` paths into `(base, category_code)`. A suffix is only treated as a category code if it appears in the declared code list. 6. **Scanner** (`scan_and_categorize`) — walks a directory, skips drafts, returns records with the first 5000 chars of body for search indexing. Paths are relative to `site_root`. 7. **Nav/search generators** — `generate_nav_yml()` emits a fixed-format YAML subset. `generate_search_json()` emits a JSON array. `merge_sections()` preserves existing section metadata on rebuild. -8. **Core build** (`run_build`) — orchestrates the full build: version check → config read → scan → merge → write nav.yml and search.json. +8. **Core build** (`run_build`) — orchestrates the full build: version check → config read → scan → merge → write nav.yml and search.json → patch `<title>` in `index.html` with `sitename` → generate PWA files if enabled. The `<title>` patch ensures crawlers and link-preview scrapers (WhatsApp, Slack, etc.) see the correct site name in the static HTML before any JavaScript runs. 9. **Template download** (`download_template`) — fetches `app/` from GitHub via the Contents API using `urllib` + `certifi` for SSL. Recursively downloads files and directories. 10. **CLI commands** (`register`, `delete`, `view`, `build`) — implemented with `click`. Entry point: `main()` → `cli()`. From 431b1c054c4c71d98194bfe48ee9ad9123df01fb Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 19 May 2026 14:14:48 +0000 Subject: [PATCH 10/19] Fix: raise on config.yml parse errors instead of silently returning empty dict A YAML parse error in config.yml (e.g. a stray tab character) caused read_config to swallow the exception and return {}, disabling categories and producing a broken nav.yml with no variants fields and wrong filenames. read_config now raises ClickException on both OSError and YAMLError. Documented in docs/knownbugs.md and docs/unreleased.md. https://claude.ai/code/session_01Xs5GyREFhjWxhS1UhW2wA8 --- docs/knownbugs.md | 15 +++++++++++++++ docs/unreleased.md | 8 ++++++++ mdcms.py | 7 +++++-- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 docs/knownbugs.md diff --git a/docs/knownbugs.md b/docs/knownbugs.md new file mode 100644 index 0000000..5999aa0 --- /dev/null +++ b/docs/knownbugs.md @@ -0,0 +1,15 @@ +# Known bugs + +Bugs that have been identified but not yet fixed. Fixed bugs are moved to the release notes. + +--- + +## Fixed in development (not yet released) + +### `config.yml` YAML parse errors were silently swallowed + +**Symptom:** A malformed `config.yml` (e.g. a stray tab character, which YAML forbids as a token starter) caused `read_config` to catch the `YAMLError` and return an empty dict. The build would proceed with no config — categories disabled, no default category code — producing a `nav.yml` that omitted `variants` fields and listed category variant files (e.g. `page.current.md`) as plain pages. Pages with category variants would not appear in the sidebar. + +**Root cause:** `read_config` caught `(OSError, yaml.YAMLError)` in a single block and silently returned `{}` on any error. + +**Fix:** `read_config` now raises `click.ClickException` on both `OSError` and `yaml.YAMLError`, aborting the build with a descriptive error message instead of continuing with an empty config. diff --git a/docs/unreleased.md b/docs/unreleased.md index 3068e82..7cc5389 100644 --- a/docs/unreleased.md +++ b/docs/unreleased.md @@ -40,3 +40,11 @@ After rebuilding a site with `mdcms build`, affected post entries in `nav.yml` g In `search.json`, these entries carry `"category": null` instead of the default category code. This is what tells the renderer to include them universally. A rebuild is required for existing sites to pick up the change. + +--- + +## Fix: `config.yml` YAML parse errors now abort the build with a clear message + +A malformed `config.yml` (e.g. a stray tab character, which YAML forbids as a token starter) previously caused `read_config` to silently return an empty dict. The build would proceed with no config — categories disabled, no default category code — producing a broken `nav.yml` with wrong filenames and missing `variants` fields, so category-variant pages would not appear in the sidebar. + +`read_config` now raises `ClickException` on both `OSError` and `yaml.YAMLError`, aborting the build with a descriptive error message instead of continuing silently with an empty config. diff --git a/mdcms.py b/mdcms.py index 10229f0..9196751 100644 --- a/mdcms.py +++ b/mdcms.py @@ -113,9 +113,12 @@ def read_config(site_path: Path) -> dict: return {} try: text = config_file.read_text(encoding="utf-8") + except OSError as e: + raise click.ClickException(f"Could not read config.yml: {e}") + try: return yaml.safe_load(text) or {} - except (OSError, yaml.YAMLError): - return {} + except yaml.YAMLError as e: + raise click.ClickException(f"config.yml is not valid YAML: {e}") def get_category_info(cfg: dict) -> dict: From b9410d4b880b495489de0889688325a57b146b33 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 19 May 2026 14:55:51 +0000 Subject: [PATCH 11/19] Fix two bugs: SPA-routing page load failure and stale service worker fetchPageFile now rejects text/html responses so servers with SPA routing (e.g. Cloudflare Pages with /* /index.html 200) no longer trick the renderer into treating a fallback index.html as a found markdown file. Category-variant pages (page.current.md with no plain page.md) now fall through correctly to their variant URL. mdcms build now writes a self-unregistering service-worker.js when pwa: no, evicting any stale caching worker left over from a previous pwa: yes build. manifest.json is also removed when pwa: no. https://claude.ai/code/session_01Xs5GyREFhjWxhS1UhW2wA8 --- app/index.html | 12 ++++++++++-- docs/knownbugs.md | 20 ++++++++++++++++++++ docs/unreleased.md | 16 ++++++++++++++++ mdcms.py | 25 +++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/app/index.html b/app/index.html index c530c36..f9c18f3 100644 --- a/app/index.html +++ b/app/index.html @@ -1135,11 +1135,19 @@ body { if (b) b.remove(); } + function _isMdResponse(r) { + // Reject HTML responses — servers with SPA routing (e.g. Cloudflare Pages with + // "/* /index.html 200") return index.html with 200 for missing files, which would + // be mistaken for a found markdown file. + const ct = r.headers.get('content-type') || ''; + return !ct.startsWith('text/html'); + } + async function fetchPageFile(conceptualFile) { // conceptualFile like "pages/foo.md". Returns { ok, text, resolvedFile } or { ok: false }. if (!categoriesUse) { const r = await fetch(conceptualFile); - if (r.ok) return { ok: true, text: await r.text(), resolvedFile: conceptualFile }; + if (r.ok && _isMdResponse(r)) return { ok: true, text: await r.text(), resolvedFile: conceptualFile }; return { ok: false }; } const base = conceptualFile.replace(/\.md$/, ''); @@ -1169,7 +1177,7 @@ body { if (seen.has(url)) continue; seen.add(url); const r = await fetch(url); - if (r.ok) return { ok: true, text: await r.text(), resolvedFile: url }; + if (r.ok && _isMdResponse(r)) return { ok: true, text: await r.text(), resolvedFile: url }; } return { ok: false }; } diff --git a/docs/knownbugs.md b/docs/knownbugs.md index 5999aa0..e341d28 100644 --- a/docs/knownbugs.md +++ b/docs/knownbugs.md @@ -6,6 +6,26 @@ Bugs that have been identified but not yet fixed. Fixed bugs are moved to the re ## Fixed in development (not yet released) +### Category-variant pages fail to load on servers with SPA routing + +**Symptom:** On Cloudflare Pages (and any other server configured to serve `index.html` with HTTP 200 for missing paths), clicking a nav item whose page only exists as a category-variant file (e.g. `page.current.md`, no plain `page.md`) showed garbled content — the raw HTML of `index.html` rendered as markdown, with the site's `<title>` text visible in the content area. + +**Root cause:** `fetchPageFile` tried the base filename (`pages/page.md`) first. Servers with SPA routing return this with HTTP 200 (serving `index.html`), so `r.ok` was true and the function returned without trying the actual variant file (`pages/page.current.md`). + +**Fix:** `fetchPageFile` now checks the `Content-Type` response header and skips any response with `text/html`, continuing to the next candidate URL. + +--- + +### Stale service worker not removed when `pwa: no` + +**Symptom:** After changing a site from `pwa: yes` to `pwa: no` and rebuilding, the old service worker remained active in browsers that had previously visited the site. Cached responses from the old build continued to be served. + +**Root cause:** `mdcms build` stopped generating PWA files when `pwa: no`, but `index.html` unconditionally registers `service-worker.js` on every page load. With no new SW to replace it, the old worker stayed installed indefinitely. + +**Fix:** `mdcms build` now writes a self-unregistering stub `service-worker.js` when `pwa: no`. On the visitor's next visit, the browser installs the stub which immediately calls `self.registration.unregister()`, evicting the stale worker. `manifest.json` is also deleted if present. + +--- + ### `config.yml` YAML parse errors were silently swallowed **Symptom:** A malformed `config.yml` (e.g. a stray tab character, which YAML forbids as a token starter) caused `read_config` to catch the `YAMLError` and return an empty dict. The build would proceed with no config — categories disabled, no default category code — producing a `nav.yml` that omitted `variants` fields and listed category variant files (e.g. `page.current.md`) as plain pages. Pages with category variants would not appear in the sidebar. diff --git a/docs/unreleased.md b/docs/unreleased.md index 7cc5389..36c6127 100644 --- a/docs/unreleased.md +++ b/docs/unreleased.md @@ -43,6 +43,22 @@ A rebuild is required for existing sites to pick up the change. --- +## Fix: category-variant pages fail to load on servers with SPA routing (e.g. Cloudflare Pages) + +When a site uses category-suffixed page files (e.g. `page.current.md`) and is hosted on a server configured with SPA fallback routing (serving `index.html` with HTTP 200 for any unknown path), the renderer's `fetchPageFile` mistook the HTML fallback for a found markdown file. It returned `index.html` content instead of falling through to try the `.current.md` variant. The page rendered the raw HTML of `index.html` as markdown, showing the `<title>` text (`sitename`) in the content area. + +`fetchPageFile` now checks the `Content-Type` response header and rejects any response with `text/html`, continuing to the next candidate URL instead. + +--- + +## Fix: stale service worker not removed when `pwa: no` + +`index.html` unconditionally registers `service-worker.js` on every page load. When a site switched from `pwa: yes` to `pwa: no`, `mdcms build` stopped generating a new service worker, but the old one remained active in browsers that had visited the site before. The stale worker continued to serve cached responses from the old build. + +`mdcms build` now writes a self-unregistering `service-worker.js` when `pwa: no`. On the visitor's next page load, the browser installs this stub worker, which immediately unregisters itself and evicts any previously cached content. `manifest.json` is also removed if present. + +--- + ## Fix: `config.yml` YAML parse errors now abort the build with a clear message A malformed `config.yml` (e.g. a stray tab character, which YAML forbids as a token starter) previously caused `read_config` to silently return an empty dict. The build would proceed with no config — categories disabled, no default category code — producing a broken `nav.yml` with wrong filenames and missing `variants` fields, so category-variant pages would not appear in the sidebar. diff --git a/mdcms.py b/mdcms.py index 9196751..ce4316e 100644 --- a/mdcms.py +++ b/mdcms.py @@ -528,6 +528,8 @@ def run_build(site_path: Path): pwa_enabled = str(cfg.get("pwa", "no")).lower() in ("yes", "true") if pwa_enabled: generate_pwa(site_path, cfg) + else: + cleanup_pwa(site_path) asset_warnings = validate_assets(site_path, cfg) for w in asset_warnings: @@ -543,6 +545,29 @@ def run_build(site_path: Path): # ─── PWA generation ─────────────────────────────────────────── +def cleanup_pwa(site_path: Path): + """When pwa: no, write a self-unregistering service worker and remove manifest.json. + + Browsers keep the previously installed service worker active until a new one is + installed. Writing a stub that immediately unregisters itself ensures any stale + caching worker is evicted on the next visit after a pwa: yes → pwa: no change. + """ + sw = site_path / "service-worker.js" + sw.write_text( + "// mdcms: PWA disabled — unregisters any previously installed service worker.\n" + "self.addEventListener('install', () => self.skipWaiting());\n" + "self.addEventListener('activate', event => {\n" + " event.waitUntil(self.registration.unregister());\n" + "});\n", + encoding="utf-8", + ) + manifest = site_path / "manifest.json" + if manifest.exists(): + manifest.unlink() + click.echo(" Removed manifest.json (pwa: no)") + click.echo(" Wrote service-worker.js (self-unregistering stub, pwa: no)") + + def generate_pwa(site_path: Path, cfg: dict): """Generate manifest.json and service-worker.js when pwa: yes.""" pwa_name = cfg.get("pwa-name", cfg.get("sitename", "MD-CMS Site")) From cc4ed7b8817c0be88372f692abc27aac3bcbabce Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 21 May 2026 14:46:46 +0000 Subject: [PATCH 12/19] docs: add missing per-category config keys to reference-config.md Documents all keys that can appear under default-category and categories entries: message, name-latin, notfoundmessage, pagenotfoundmessage, font, and line-height. Adds a summary table and updates the full example to show these keys in context. --- docs/reference-config.md | 67 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/docs/reference-config.md b/docs/reference-config.md index 6c91062..c0b429f 100644 --- a/docs/reference-config.md +++ b/docs/reference-config.md @@ -151,16 +151,40 @@ categories-use: yes # Enable the category system. Default: no. default-category: # The category used when no ?cat= parameter is in the URL. code: en # Short code. Used in filenames (page.en.md) and URL params. - name: English # Display name shown in the category selector. + name: English # Display name shown in the category dropdown list. + message: English # Label shown on the selector bar (trigger button). Falls back to name. + name-latin: English # Secondary label shown in the dropdown alongside name. Use when name + # is in a non-Latin script (e.g. Arabic, Devanagari) to aid recognition. + # Omit if name is already Latin or identical to name. direction: ltr # Text direction. ltr or rtl. Default: ltr. + # rtl flips the nav position and content text direction. + notfoundmessage: "Not available in this language" + # Short note shown in the dropdown when no variant exists for the + # current page. Also enables fallback: the renderer will fall back to + # the default-category content instead of hiding the page. + # Omit to hide the category from the dropdown when no variant exists. + pagenotfoundmessage: "This page is not yet available in English." + # Message shown in the content area when a page cannot be fetched for + # this category. Overrides the top-level pagenotfoundmessage. + font: NotoNastaliqUrdu-Regular.ttf + # Font filename inside assets/fonts/. Loaded on demand when this + # category is activated. Useful for scripts that need a specific font. + line-height: 2.8 # Line height override for this category. Useful for scripts like + # Nastaliq that need extra vertical space. Restores to theme default + # when switching away. -categories: # Additional categories. +categories: # Additional categories. Each entry supports the same keys as + # default-category above. - code: nb name: Norsk direction: ltr - code: ar name: عربي - direction: rtl # RTL flips nav position and content text direction. + name-latin: Arabic + direction: rtl + notfoundmessage: "غير متاح" + font: NotoNastaliqUrdu-Regular.ttf + line-height: 2.8 categories-sectionnames: same # How section names are shown per category. # same: all categories share one section name (defaultname in nav.yml). @@ -170,6 +194,20 @@ categories-selecticon: globe # Icon shown in the category selector bar. SVG na categories-selecttext: "Language" # Label shown next to the icon in the category selector bar. ``` +### Per-category keys summary + +| Key | Required | Description | +|---|---|---| +| `code` | Yes | Short identifier used in filenames (`page.nb.md`) and the `?cat=` URL param. | +| `name` | Yes | Display name shown in the dropdown list. | +| `message` | No | Label shown on the selector trigger button. Falls back to `name`. | +| `name-latin` | No | Secondary label in the dropdown, shown alongside `name` when `name` uses a non-Latin script. | +| `direction` | No | `ltr` or `rtl`. Default: `ltr`. RTL flips nav and content direction. | +| `notfoundmessage` | No | Short note shown in the dropdown when no variant exists for the current page. Also enables fallback to default-category content. | +| `pagenotfoundmessage` | No | Message shown in the content area when a page cannot be fetched for this category. Overrides the top-level `pagenotfoundmessage`. | +| `font` | No | Font filename from `assets/fonts/`. Loaded on demand when this category is activated. | +| `line-height` | No | Body line height override for this category. Restores to theme default when switching away. | + --- ## Reusable callout messages @@ -228,6 +266,29 @@ offline-message: nb: "Du er frakoblet. Koble til og last inn på nytt." language: en +pagenotfoundmessage: "Please select a page to continue." + +categories-use: yes +default-category: + code: en + name: English + direction: ltr +categories: + - code: nb + name: Norsk + direction: ltr + notfoundmessage: "Ikke tilgjengelig på norsk" + - code: ar + name: عربي + name-latin: Arabic + direction: rtl + notfoundmessage: "غير متاح" + pagenotfoundmessage: "هذه الصفحة غير متاحة." + font: NotoNastaliqUrdu-Regular.ttf + line-height: 2.8 +categories-sectionnames: same +categories-selecticon: globe +categories-selecttext: "Language" callouts: aitranslation: From ef4197fa83e8b3b1157c2d78ffe82adc8ada5701 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 21 May 2026 15:02:31 +0000 Subject: [PATCH 13/19] feat: add visibilityifnocontent per-category config key When set to visible, the category always appears in the selector even when no variant exists for the current page. Navigating to such a page shows pagenotfoundmessage with no fallback to default-category content. Default behaviour (hidden) is unchanged. Updates pageShouldDisplay and visibleCategoryCodesForCurrentPage to honour the new key alongside the existing notfoundmessage logic. Docs updated with key description, summary table, and full example. --- app/index.html | 12 +++++++----- docs/reference-config.md | 10 +++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/index.html b/app/index.html index d811dc9..bb19f87 100644 --- a/app/index.html +++ b/app/index.html @@ -952,7 +952,7 @@ body { // Category state (phase 3) let categoriesUse = false; - let categoriesList = []; // [{code, name, direction, message, notfoundmessage, pagenotfoundmessage, font, ...}] + let categoriesList = []; // [{code, name, direction, message, notfoundmessage, pagenotfoundmessage, visibilityifnocontent, font, ...}] let categoriesByCode = {}; // code → category object let defaultCategoryCode = null; let activeCategory = null; // current code @@ -1202,13 +1202,14 @@ body { // - Home page: always show (per config.homepage or default 'pages/home.md') // - Variant exists for active category: show // - Active category has notfoundmessage: show (renderer falls back to default language) + // - Active category has visibilityifnocontent: visible: show (renderer shows pagenotfoundmessage) // - Otherwise: hide if (!categoriesUse) return true; if (page.file === defaultPage()) return true; const variants = page.variants || []; if (variants.includes(activeCategory)) return true; const cat = categoriesByCode[activeCategory]; - return !!(cat && cat.notfoundmessage); + return !!(cat && (cat.notfoundmessage || cat.visibilityifnocontent === 'visible')); } // ─── Theme ──────────────────────────────────────────────── @@ -2243,7 +2244,8 @@ function fmtDatetime(dtStr) { function visibleCategoryCodesForCurrentPage() { // Which categories should appear in the dropdown: // - the variant exists for this page, OR - // - the category has a notfoundmessage + // - the category has a notfoundmessage (fallback to default content), OR + // - the category has visibilityifnocontent: visible (shows pagenotfoundmessage instead) // - always include the active category so user can see what they're on const out = new Set(); const page = currentPage @@ -2251,8 +2253,8 @@ function fmtDatetime(dtStr) { : null; categoriesList.forEach(cat => { const hasVariant = !page || !page.variants || page.variants.includes(cat.code); - const hasMsg = !!cat.notfoundmessage; - if (hasVariant || hasMsg || cat.code === activeCategory) out.add(cat.code); + const alwaysVisible = !!cat.notfoundmessage || cat.visibilityifnocontent === 'visible'; + if (hasVariant || alwaysVisible || cat.code === activeCategory) out.add(cat.code); }); return out; } diff --git a/docs/reference-config.md b/docs/reference-config.md index c0b429f..0c0d829 100644 --- a/docs/reference-config.md +++ b/docs/reference-config.md @@ -163,6 +163,12 @@ default-category: # The category used when no ?cat= parameter is in # current page. Also enables fallback: the renderer will fall back to # the default-category content instead of hiding the page. # Omit to hide the category from the dropdown when no variant exists. + visibilityifnocontent: hidden # hidden (default) or visible. + # hidden: category disappears from the selector when no variant exists + # for the current page (unless notfoundmessage is also set). + # visible: category stays in the selector regardless. When the user + # navigates to a page with no variant, pagenotfoundmessage is shown + # in the content area. No fallback to default-category content. pagenotfoundmessage: "This page is not yet available in English." # Message shown in the content area when a page cannot be fetched for # this category. Overrides the top-level pagenotfoundmessage. @@ -204,6 +210,7 @@ categories-selecttext: "Language" # Label shown next to the icon in the categor | `name-latin` | No | Secondary label in the dropdown, shown alongside `name` when `name` uses a non-Latin script. | | `direction` | No | `ltr` or `rtl`. Default: `ltr`. RTL flips nav and content direction. | | `notfoundmessage` | No | Short note shown in the dropdown when no variant exists for the current page. Also enables fallback to default-category content. | +| `visibilityifnocontent` | No | `hidden` (default) or `visible`. `visible` keeps the category in the selector when no variant exists; navigating to it shows `pagenotfoundmessage` with no fallback to default content. | | `pagenotfoundmessage` | No | Message shown in the content area when a page cannot be fetched for this category. Overrides the top-level `pagenotfoundmessage`. | | `font` | No | Font filename from `assets/fonts/`. Loaded on demand when this category is activated. | | `line-height` | No | Body line height override for this category. Restores to theme default when switching away. | @@ -277,7 +284,8 @@ categories: - code: nb name: Norsk direction: ltr - notfoundmessage: "Ikke tilgjengelig på norsk" + visibilityifnocontent: visible + pagenotfoundmessage: "Denne siden er ikke tilgjengelig på norsk ennå." - code: ar name: عربي name-latin: Arabic From 4c8ca31651fe80b2de38bc5ce3f1b0828508e668 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 21 May 2026 15:27:10 +0000 Subject: [PATCH 14/19] fix: hidden categories now auto-switch to default instead of showing error Two bugs fixed in navigateTo: 1. When a category has visibilityifnocontent: hidden (default) and the current page has no variant for it, the renderer now silently switches to the default category before fetching. Previously the category stayed active (kept visible in the selector via the activeCategory guard), the fetch failed, and an error page was shown. 2. The offline message stored in localStorage was shown for any failed fetch, not just genuine offline situations. Now gated on !navigator.onLine so missing pages always show pagenotfoundmessage instead. --- app/index.html | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/app/index.html b/app/index.html index af6b645..9ac8e70 100644 --- a/app/index.html +++ b/app/index.html @@ -2653,6 +2653,24 @@ function fmtDatetime(dtStr) { const contentEl = document.getElementById('pageContent'); highlightNav(file); + // If the active category is "hidden" (no notfoundmessage, not visibilityifnocontent:visible) + // and this page has no variant for it, silently switch to the default category instead of + // showing an error. + if (categoriesUse && activeCategory !== defaultCategoryCode && file !== defaultPage()) { + const cat = categoriesByCode[activeCategory]; + const isHidden = cat && !cat.notfoundmessage && cat.visibilityifnocontent !== 'visible'; + if (isHidden) { + const pageEntry = navData.find(p => p.file === file); + const hasVariant = !pageEntry || pageEntry.uncategorized + || !(pageEntry.variants && pageEntry.variants.length) + || pageEntry.variants.includes(activeCategory); + if (!hasVariant) { + setActiveCategory(defaultCategoryCode); + return; + } + } + } + // Build a clean URL: keep origin + path, set ?cat only when non-default, set hash to conceptual file const u = new URL(window.location); if (categoriesUse && activeCategory && activeCategory !== defaultCategoryCode) { @@ -2667,7 +2685,7 @@ function fmtDatetime(dtStr) { const result = await fetchPageFile(file); if (!result.ok) { - const offlineMsg = localStorage.getItem('mdcms-offline'); + const offlineMsg = !navigator.onLine && localStorage.getItem('mdcms-offline'); const bodyMsg = offlineMsg ? `<p>${offlineMsg}</p>` : `<p>${pageNotFoundMessage()}</p>`; From 099320cde7cebece34a96ebb9fc49edcec60015c Mon Sep 17 00:00:00 2001 From: kbenestad <kristian@benestad.net> Date: Thu, 21 May 2026 23:21:43 +0700 Subject: [PATCH 15/19] Create .gitkeep --- themes/Operating Systems/.gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 themes/Operating Systems/.gitkeep diff --git a/themes/Operating Systems/.gitkeep b/themes/Operating Systems/.gitkeep new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/themes/Operating Systems/.gitkeep @@ -0,0 +1 @@ + From a09df3a63ce43d70cdfd4f3d26dd949947bec857 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 21 May 2026 15:46:43 +0000 Subject: [PATCH 16/19] Add tabs and accordion content components to index.html MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements four new mdcms fenced-block types: tab-underline / tab, tab-filled, accordion-underline / accordion, accordion-filled Each block reads items: from a YAML body. Tab state and accordion open/close are managed with aria-selected / aria-expanded and data-open attributes. Markdown content inside each item is rendered with the same pipeline as the surrounding page. Adds computeDerivedTokens() — called on every applyTheme() — which computes --mdcms-bar, --mdcms-filled-bg/border/fg, --mdcms-strip-border from the active palette. Uses HSL chroma (S × (1-|2L-1|)) instead of raw HSL S for the bold-nav heuristic, avoiding a false-positive on near-white nav colours like the default #F8FAFC. Adds app/pages/tabs-accordions.md as a visual test page and docs/unreleased.md to track this change ahead of the next release. https://claude.ai/code/session_01SFMh7PDxJjvvo5dYbCCFFs --- app/index.html | 375 +++++++++++++++++++++++++++++++++++ app/nav.yml | 4 + app/pages/tabs-accordions.md | 78 ++++++++ app/search.json | 12 ++ app/service-worker.js | 11 +- docs/unreleased.md | 138 +++++++++++++ 6 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 app/pages/tabs-accordions.md diff --git a/app/index.html b/app/index.html index 9ac8e70..de8a6ab 100644 --- a/app/index.html +++ b/app/index.html @@ -917,6 +917,146 @@ body { } .post-load-more:hover { background: var(--nav-hover-bg); } +/* ═══════════════════════════════════════════ + TAG SYSTEM: TABS + ═══════════════════════════════════════════ */ +.mdcms-tabs { margin: 1.25rem 0; } + +/* Underline variant */ +.mdcms-tabs-underline .mdcms-tabs-strip { + display: flex; + flex-wrap: wrap; + gap: 0 18px; + border-bottom: 1px solid var(--mdcms-strip-border, color-mix(in srgb, var(--font-colour) 12%, transparent)); +} +.mdcms-tabs-underline .mdcms-tab-btn { + padding: 8px 2px; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + background: transparent; + cursor: pointer; + font-size: inherit; + font-family: inherit; + font-weight: 500; + color: var(--font-colour-muted); + line-height: inherit; + transition: color 0.15s; +} +.mdcms-tabs-underline .mdcms-tab-btn:hover { color: var(--font-colour); } +.mdcms-tabs-underline .mdcms-tab-btn[aria-selected="true"] { + font-weight: 600; + color: var(--font-colour); + border-bottom-color: var(--accent); +} + +/* Filled variant */ +.mdcms-tabs-filled .mdcms-tabs-strip { display: flex; flex-wrap: wrap; gap: 4px; } +.mdcms-tabs-filled .mdcms-tab-btn { + padding: 6px 11px; + border-radius: 4px; + border: 1px solid var(--mdcms-filled-border, rgba(var(--accent-rgb), 0.30)); + background: var(--mdcms-filled-bg, rgba(var(--accent-rgb), 0.10)); + color: var(--mdcms-filled-fg-muted, var(--font-colour-muted)); + cursor: pointer; + font-size: inherit; + font-family: inherit; + font-weight: 400; + line-height: inherit; + transition: color 0.15s; +} +.mdcms-tabs-filled .mdcms-tab-btn:hover { color: var(--mdcms-filled-fg, var(--font-colour)); } +.mdcms-tabs-filled .mdcms-tab-btn[aria-selected="true"] { + background: var(--mdcms-bg, var(--bg-main)); + border-color: rgba(var(--accent-rgb), 0.55); + color: var(--accent); + font-weight: 600; +} + +/* Shared panel */ +.mdcms-tabs-panel { padding-top: 1rem; } +.mdcms-tabs-panel[hidden] { display: none; } +.mdcms-tabs-panel > *:first-child { margin-top: 0; } +.mdcms-tabs-panel > *:last-child { margin-bottom: 0; } + +/* ═══════════════════════════════════════════ + TAG SYSTEM: ACCORDIONS + ═══════════════════════════════════════════ */ +.mdcms-accordion { margin: 1.25rem 0; } +.mdcms-accordion-btn { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + border: none; + background: transparent; + cursor: pointer; + font-size: inherit; + font-family: inherit; + text-align: left; + line-height: inherit; +} +.mdcms-accordion-chevron { + display: inline-flex; + flex-shrink: 0; + width: 1.1em; + height: 1.1em; + transition: transform 0.2s ease; + transform: rotate(0deg); +} +.mdcms-accordion-item[data-open="false"] .mdcms-accordion-chevron { transform: rotate(-90deg); } +.mdcms-accordion-body[hidden] { display: none; } +.mdcms-accordion-body > *:first-child { margin-top: 0; } +.mdcms-accordion-body > *:last-child { margin-bottom: 0; } + +/* Underline variant */ +.mdcms-accordion-underline .mdcms-accordion-item { margin-bottom: 6px; } +.mdcms-accordion-underline .mdcms-accordion-btn { + padding: 8px 2px 9px; + border-bottom: 2px solid var(--mdcms-bar, var(--accent)); + font-weight: 600; + font-size: 0.75rem; + color: var(--font-colour); +} +.mdcms-accordion-underline .mdcms-accordion-chevron { color: var(--mdcms-bar, var(--accent)); } +.mdcms-accordion-underline .mdcms-accordion-body { + border-left: 1px solid var(--mdcms-bar, var(--accent)); + border-right: 1px solid var(--mdcms-bar, var(--accent)); + border-bottom: 1px solid var(--mdcms-bar, var(--accent)); + border-radius: 0 0 3px 3px; + padding: 8px 10px 9px; + color: var(--font-colour-muted); +} + +/* Filled variant — closed */ +.mdcms-accordion-filled .mdcms-accordion-item { margin-bottom: 6px; } +.mdcms-accordion-filled .mdcms-accordion-item[data-open="true"] { margin-bottom: 8px; } +.mdcms-accordion-filled .mdcms-accordion-btn { + padding: 8px 11px; + border-radius: 4px; + border: 1px solid var(--mdcms-filled-border, rgba(var(--accent-rgb), 0.30)); + background: var(--mdcms-filled-bg, rgba(var(--accent-rgb), 0.10)); + color: var(--mdcms-filled-fg, var(--font-colour)); +} +.mdcms-accordion-filled .mdcms-accordion-chevron { color: var(--mdcms-filled-fg, var(--font-colour)); } + +/* Filled variant — open: item becomes the outer frame */ +.mdcms-accordion-filled .mdcms-accordion-item[data-open="true"] { + border: 1px solid var(--mdcms-bar, var(--accent)); + border-radius: 4px; + overflow: hidden; +} +.mdcms-accordion-filled .mdcms-accordion-item[data-open="true"] > .mdcms-accordion-btn { + border: none; + border-radius: 0; +} +.mdcms-accordion-filled .mdcms-accordion-body { + background: var(--mdcms-bg, var(--bg-main)); + padding: 8px 11px 9px; + color: var(--font-colour-muted); +} + @media print { .sidebar, .topbar, .scroll-top, .hamburger, .mobile-header, .theme-toggle, .search-container { display: none !important; } @@ -1236,6 +1376,7 @@ body { btn.appendChild(iconEl(isDark ? 'light_mode' : 'dark_mode')); btn.appendChild(el('span', { textContent: isDark ? 'Light mode' : 'Dark mode' })); } + computeDerivedTokens(); } function getInitialTheme() { @@ -1467,6 +1608,10 @@ body { const fenceType = codeLang === 'mdcms' ? '' : codeLang.slice('mdcms '.length).trim(); const fullText = fenceType ? (fenceType + '\n' + (codeText || '')) : (codeText || ''); const tag = parseMdcmsTag(fullText); + // For tab/accordion blocks, preserve the raw fence body to avoid trim() breaking YAML indentation. + if (/^tab(-underline|-filled)?$|^accordion(-underline|-filled)?$/.test(tag.tagName)) { + tag.rawBody = codeText || ''; + } const encoded = JSON.stringify(tag).replace(/&/g, '&').replace(/"/g, '"'); return '<div class="mdcms-tag" data-config="' + encoded + '"></div>'; } @@ -1956,6 +2101,95 @@ function fmtDatetime(dtStr) { return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'; } + function parseColorToHex(val) { + if (!val) return null; + val = val.trim(); + if (val.startsWith('#')) { + if (val.length === 4) return '#' + val[1]+val[1]+val[2]+val[2]+val[3]+val[3]; + return val.toLowerCase(); + } + var m = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (m) return '#' + [m[1],m[2],m[3]].map(function(n) { return parseInt(n).toString(16).padStart(2,'0'); }).join(''); + return null; + } + + function relativeLuminance(hex) { + hex = hex.replace('#',''); + if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; + var r = parseInt(hex.substr(0,2),16)/255; + var g = parseInt(hex.substr(2,2),16)/255; + var b = parseInt(hex.substr(4,2),16)/255; + function lin(c) { return c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); } + return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b); + } + + function hexToHsl(hex) { + hex = hex.replace('#',''); + if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; + var r = parseInt(hex.substr(0,2),16)/255; + var g = parseInt(hex.substr(2,2),16)/255; + var b = parseInt(hex.substr(4,2),16)/255; + var max = Math.max(r,g,b), min = Math.min(r,g,b); + var h = 0, s = 0, l = (max+min)/2; + if (max !== min) { + var d = max - min; + s = l > 0.5 ? d/(2-max-min) : d/(max+min); + switch(max) { + case r: h = ((g-b)/d + (g<b?6:0))/6; break; + case g: h = ((b-r)/d + 2)/6; break; + case b: h = ((r-g)/d + 4)/6; break; + } + } + return [h, s, l]; + } + + // HSL chroma: S × (1-|2L-1|) — gives perceptually meaningful colorfulness + // unlike raw HSL S which is artificially high near white/black. + function hslChroma(hex) { + var hsl = hexToHsl(hex); + return hsl[1] * (1 - Math.abs(2 * hsl[2] - 1)); + } + + function computeDerivedTokens() { + var cs = getComputedStyle(document.documentElement); + var bgHex = parseColorToHex(cs.getPropertyValue('--bg-main').trim()); + var navHex = parseColorToHex(cs.getPropertyValue('--bg-nav').trim()); + var textHex = parseColorToHex(cs.getPropertyValue('--font-colour').trim()); + var mutedHex = parseColorToHex(cs.getPropertyValue('--font-colour-muted').trim()); + var accentHex = parseColorToHex(cs.getPropertyValue('--accent').trim()); + + if (!bgHex || !navHex || !textHex || !mutedHex || !accentHex) return; + + var bgL = relativeLuminance(bgHex); + var navL = relativeLuminance(navHex); + var navC = hslChroma(navHex); + var bgC = hslChroma(bgHex); + var isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + + var navIsAccent = Math.abs(bgL - navL) > 0.22 || (navC > 0.35 && Math.abs(navC - bgC) > 0.25); + var navIsInverted = Math.abs(bgL - navL) > 0.35; + + var navText = navIsInverted ? (navL < 0.5 ? '#F2EFE8' : '#161412') : textHex; + var navTextMuted = navIsInverted ? hexToRgba(navText, 0.6) : mutedHex; + + var filledBg = navIsAccent ? navHex : hexToRgba(accentHex, 0.10); + var filledBorder = navIsAccent ? hexToRgba(navText, 0.18) : hexToRgba(accentHex, 0.30); + var filledFg = navIsAccent ? navText : textHex; + var filledFgMuted = navIsAccent ? navTextMuted : mutedHex; + var barColor = navIsAccent ? navHex : accentHex; + var stripAlpha = isDark ? 0.14 : 0.10; + + var root = document.documentElement; + root.style.setProperty('--mdcms-bg', bgHex); + root.style.setProperty('--mdcms-accent', accentHex); + root.style.setProperty('--mdcms-filled-bg', filledBg); + root.style.setProperty('--mdcms-filled-border', filledBorder); + root.style.setProperty('--mdcms-filled-fg', filledFg); + root.style.setProperty('--mdcms-filled-fg-muted',filledFgMuted); + root.style.setProperty('--mdcms-bar', barColor); + root.style.setProperty('--mdcms-strip-border', hexToRgba(textHex, stripAlpha)); + } + function renderTocTag(container) { const byCode = {}; navSections.forEach(s => { byCode[s.code] = s; }); @@ -2012,6 +2246,143 @@ function fmtDatetime(dtStr) { container.replaceWith(div); } + function renderTabsTag(container, cfg) { + var variant = cfg.tagName === 'tab' ? 'tab-underline' : cfg.tagName; + var isFilled = variant === 'tab-filled'; + var varClass = isFilled ? 'filled' : 'underline'; + + var items = []; + try { + // Use rawBody (pre-trim YAML) when available; fall back to reconstructed form. + var rawYaml = cfg.rawBody !== undefined ? cfg.rawBody : ('items:\n' + (cfg.body || '')); + var parsed = jsyaml.load(rawYaml); + items = (parsed && parsed.items) || []; + } catch (e) { + container.textContent = 'Error parsing tab items.'; + return; + } + if (!items.length) { container.textContent = 'No tab items.'; return; } + + var selectedIdx = items.findIndex(function(it) { return it && it.default === 'selected'; }); + if (selectedIdx < 0) selectedIdx = 0; + + var wrapper = el('div', { className: 'mdcms-tabs mdcms-tabs-' + varClass }); + var strip = el('div', { className: 'mdcms-tabs-strip', role: 'tablist' }); + var panels = []; + + items.forEach(function(item, i) { + if (!item) return; + var isSelected = i === selectedIdx; + + var btn = el('button', { + className: 'mdcms-tab-btn', + role: 'tab', + type: 'button', + 'aria-selected': String(isSelected) + }); + var titleStyle = item['title-style'] || ''; + var lvlMatch = titleStyle.match(/^(#{1,6})$/); + var titleSpan; + if (lvlMatch) { + titleSpan = el('span', { role: 'heading', 'aria-level': String(lvlMatch[1].length) }); + titleSpan.textContent = item.title || ''; + } else { + titleSpan = el('span', { textContent: item.title || '' }); + } + btn.appendChild(titleSpan); + strip.appendChild(btn); + + var panel = el('div', { className: 'mdcms-tabs-panel', role: 'tabpanel' }); + panel.innerHTML = renderMarkdown(String(item.content || '')); + if (!isSelected) panel.setAttribute('hidden', ''); + panels.push(panel); + + btn.addEventListener('click', (function(idx) { + return function() { + strip.querySelectorAll('.mdcms-tab-btn').forEach(function(b, j) { + b.setAttribute('aria-selected', String(j === idx)); + if (j === idx) panels[j].removeAttribute('hidden'); + else panels[j].setAttribute('hidden', ''); + }); + }; + })(i)); + }); + + wrapper.appendChild(strip); + panels.forEach(function(p) { wrapper.appendChild(p); }); + container.replaceWith(wrapper); + } + + function renderAccordionTag(container, cfg) { + var variant = cfg.tagName === 'accordion' ? 'accordion-underline' : cfg.tagName; + var isFilled = variant === 'accordion-filled'; + var varClass = isFilled ? 'filled' : 'underline'; + + var items = []; + try { + var rawYaml = cfg.rawBody !== undefined ? cfg.rawBody : ('items:\n' + (cfg.body || '')); + var parsed = jsyaml.load(rawYaml); + items = (parsed && parsed.items) || []; + } catch (e) { + container.textContent = 'Error parsing accordion items.'; + return; + } + if (!items.length) { container.textContent = 'No accordion items.'; return; } + + var CHEVRON_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>'; + + var wrapper = el('div', { className: 'mdcms-accordion mdcms-accordion-' + varClass }); + + items.forEach(function(item) { + if (!item) return; + var isOpen = item.default === 'open'; + + var itemEl = el('div', { className: 'mdcms-accordion-item' }); + itemEl.setAttribute('data-open', String(isOpen)); + + var btn = el('button', { + className: 'mdcms-accordion-btn', + type: 'button', + 'aria-expanded': String(isOpen) + }); + + var titleStyle = item['title-style'] || ''; + var lvlMatch = titleStyle.match(/^(#{1,6})$/); + var titleEl = el('span', { className: 'mdcms-accordion-title' }); + if (lvlMatch) { + var heading = el('span', { role: 'heading', 'aria-level': String(lvlMatch[1].length) }); + heading.textContent = item.title || ''; + titleEl.appendChild(heading); + } else { + titleEl.textContent = item.title || ''; + } + btn.appendChild(titleEl); + + var chevron = el('span', { className: 'mdcms-accordion-chevron' }); + chevron.innerHTML = CHEVRON_SVG; + btn.appendChild(chevron); + + var body = el('div', { className: 'mdcms-accordion-body' }); + body.innerHTML = renderMarkdown(String(item.content || '')); + if (!isOpen) body.setAttribute('hidden', ''); + + btn.addEventListener('click', function() { + var open = itemEl.getAttribute('data-open') === 'true'; + var next = !open; + itemEl.setAttribute('data-open', String(next)); + btn.setAttribute('aria-expanded', String(next)); + if (next) body.removeAttribute('hidden'); + else body.setAttribute('hidden', ''); + }); + + itemEl.appendChild(btn); + itemEl.appendChild(body); + wrapper.appendChild(itemEl); + }); + + container.replaceWith(wrapper); + } + function hydrateMdcmsTags() { document.querySelectorAll('.mdcms-tag').forEach(function(tagEl) { try { @@ -2020,6 +2391,10 @@ function fmtDatetime(dtStr) { renderCalloutTag(tagEl, cfg); } else if (cfg.tagName === 'toc') { renderTocTag(tagEl); + } else if (/^tab(-underline|-filled)?$/.test(cfg.tagName)) { + renderTabsTag(tagEl, cfg); + } else if (/^accordion(-underline|-filled)?$/.test(cfg.tagName)) { + renderAccordionTag(tagEl, cfg); } else { renderPostTag(tagEl, cfg); } diff --git a/app/nav.yml b/app/nav.yml index 655a3c5..331515a 100644 --- a/app/nav.yml +++ b/app/nav.yml @@ -16,3 +16,7 @@ pages: - file: pages/docs.md title: Docs sort: 300 + + - file: pages/tabs-accordions.md + title: Tabs & Accordions + sort: 400 diff --git a/app/pages/tabs-accordions.md b/app/pages/tabs-accordions.md new file mode 100644 index 0000000..d21a541 --- /dev/null +++ b/app/pages/tabs-accordions.md @@ -0,0 +1,78 @@ +--- +title: Tabs & Accordions +sort: 400 +--- + +# Tabs & Accordions + +## Tab — Underline variant + +```mdcms tab-underline +items: + - title: Install + default: selected + content: | + Install with `npm i mdcms` or `pnpm add mdcms`. + - title: Configure + content: | + Drop a `mdcms.config.yaml` next to your content folder. + - title: Deploy + content: | + Any static host. The build emits plain HTML. +``` + +## Tab — Filled variant + +```mdcms tab-filled +items: + - title: Overview + default: selected + content: | + MD-CMS is a markdown-based static site system with no build step. + - title: Features + content: | + - Sidebar navigation with sections + - Full-text search via Fuse.js + - PWA support with offline caching + - Dark / light theme toggle + - title: Architecture + content: | + Two parts: `mdcms.py` (CLI) and `app/index.html` (browser renderer). +``` + +## Accordion — Underline variant + +```mdcms accordion-underline +items: + - title: What is MD-CMS? + default: open + content: | + MD-CMS is a single-file browser renderer that reads markdown, config, + and nav at runtime entirely client-side. No build pipeline, no compilation. + - title: How do I install it? + content: | + Run `pip install mdcms` or download a binary from the GitHub releases page. + - title: Does it work offline? + content: | + Yes — run `mdcms fetch-deps` to bundle all vendor assets locally, then + enable `pwa: yes` in `config.yml` for full offline support. +``` + +## Accordion — Filled variant + +```mdcms accordion-filled +items: + - title: Can I use custom themes? + default: open + content: | + Yes. Create a `theme.yml` file and point to it with `theme: theme.yml` in + your `config.yml`. The theme controls colours, fonts, and layout. + - title: What markdown features are supported? + content: | + GFM (GitHub Flavored Markdown): tables, task lists, fenced code blocks, + strikethrough, and autolinks. Syntax highlighting via highlight.js. + - title: Can I nest categories? + content: | + Categories are flat (no nesting), but nav sections support a `parent:` + key for two-level sidebar grouping. +``` diff --git a/app/search.json b/app/search.json index d6dfd7a..cd54333 100644 --- a/app/search.json +++ b/app/search.json @@ -34,5 +34,17 @@ "modified": "", "language": "en", "body": "# Phase 7 — PWA Test\n\nThis page verifies the service worker and manifest generated by `mdcms build` when `pwa: yes` is set in `config.yml`.\n\n## Test procedure\n\n1. Run `python3 mdcms.py build --path app/` — confirm `manifest.json` and `service-worker.js` appear in `app/`\n2. Load `http://localhost:8800` — service worker registers on first load\n3. Navigate to the **About** and **Docs** pages so they are fetched and cached\n4. Stop the HTTP server (`Ctrl+C` in its terminal)\n5. Reload — site should load fully from the service worker cache\n6. Navigate between pages — all should work offline\n7. Check that a page not yet visited shows the offline message\n\n## What to look for\n\n- `manifest.json` and `service-worker.js` exist after build\n- DevTools → Application → Service Workers: status **activated and running**\n- DevTools → Application → Cache Storage: cache named `mdcms-xxxxxxxx` with all files listed\n- Site loads fully with server stopped\n- Offline message (`config.yml: offline-message`) appears for uncached pages\n" + }, + { + "file": "pages/tabs-accordions.md", + "title": "Tabs & Accordions", + "section-id": null, + "keywords": "", + "description": "", + "author": null, + "created": "", + "modified": "", + "language": "en", + "body": "# Tabs & Accordions\n\n## Tab — Underline variant\n\n```mdcms tab-underline\nitems:\n - title: Install\n default: selected\n content: |\n Install with `npm i mdcms` or `pnpm add mdcms`.\n - title: Configure\n content: |\n Drop a `mdcms.config.yaml` next to your content folder.\n - title: Deploy\n content: |\n Any static host. The build emits plain HTML.\n```\n\n## Tab — Filled variant\n\n```mdcms tab-filled\nitems:\n - title: Overview\n default: selected\n content: |\n MD-CMS is a markdown-based static site system with no build step.\n - title: Features\n content: |\n - Sidebar navigation with sections\n - Full-text search via Fuse.js\n - PWA support with offline caching\n - Dark / light theme toggle\n - title: Architecture\n content: |\n Two parts: `mdcms.py` (CLI) and `app/index.html` (browser renderer).\n```\n\n## Accordion — Underline variant\n\n```mdcms accordion-underline\nitems:\n - title: What is MD-CMS?\n default: open\n content: |\n MD-CMS is a single-file browser renderer that reads markdown, config,\n and nav at runtime entirely client-side. No build pipeline, no compilation.\n - title: How do I install it?\n content: |\n Run `pip install mdcms` or download a binary from the GitHub releases page.\n - title: Does it work offline?\n content: |\n Yes — run `mdcms fetch-deps` to bundle all vendor assets locally, then\n enable `pwa: yes` in `config.yml` for full offline support.\n```\n\n## Accordion — Filled variant\n\n```mdcms accordion-filled\nitems:\n - title: Can I use custom themes?\n default: open\n content: |\n Yes. Create a `theme.yml` file and point to it with `theme: theme.yml` in\n your `config.yml`. The theme controls colours, fonts, and layout.\n - title: What markdown features are supported?\n content: |\n GFM (GitHub Flavored Markdown): tables, task lists, fenced code blocks,\n strikethrough, and autolinks. Syntax highlighting via highlight.js.\n - title: Can I nest categories?\n content: |\n Categories are flat (no nesting), but nav sections support a `parent:`\n key for two-level sidebar grouping.\n```\n" } ] \ No newline at end of file diff --git a/app/service-worker.js b/app/service-worker.js index a357757..791adf2 100644 --- a/app/service-worker.js +++ b/app/service-worker.js @@ -1,5 +1,5 @@ // mdcms service worker — generated by mdcms build -const CACHE_NAME = 'mdcms-eb384247'; +const CACHE_NAME = 'mdcms-a1862733'; const PRECACHE_URLS = [ "index.html", "config.yml", @@ -9,20 +9,29 @@ const PRECACHE_URLS = [ "pages/about.md", "pages/docs.md", "pages/home.md", + "pages/tabs-accordions.md", "posts/.gitkeep", "assets/fonts/.gitkeep", "assets/icons/.gitkeep", + "assets/icons/add.svg", "assets/icons/arrow_drop_down.svg", "assets/icons/arrow_right.svg", + "assets/icons/collapse_content.svg", "assets/icons/dangerous.svg", "assets/icons/dark_mode.svg", "assets/icons/error.svg", "assets/icons/exclamation.svg", + "assets/icons/expand_content.svg", "assets/icons/history.svg", "assets/icons/info.svg", + "assets/icons/keyboard_arrow_down.svg", + "assets/icons/keyboard_arrow_right.svg", + "assets/icons/keyboard_double_arrow_down.svg", + "assets/icons/keyboard_double_arrow_right.svg", "assets/icons/language.svg", "assets/icons/light_mode.svg", "assets/icons/menu.svg", + "assets/icons/minimize.svg", "assets/icons/mobile_arrow_down.svg", "assets/icons/report.svg", "assets/icons/search.svg", diff --git a/docs/unreleased.md b/docs/unreleased.md index 36c6127..646d081 100644 --- a/docs/unreleased.md +++ b/docs/unreleased.md @@ -4,6 +4,144 @@ Changes merged into `development` that have not yet been released to `main`. --- +## Tabs & Accordions (`app/index.html`) + +Four new `mdcms` fenced-block types for rich content layout. All four variants read from the active theme automatically — no new config keys, no per-theme overrides needed. + +### Block types + +| Language tag | Alias for | Renders as | +|---|---|---| +| `tab-underline` | — | Tab strip, active tab marked with underline | +| `tab` | `tab-underline` | (same) | +| `tab-filled` | — | Tab strip, tabs as filled chips | +| `accordion-underline` | — | Stacked accordion, header underline style | +| `accordion` | `accordion-underline` | (same) | +| `accordion-filled` | — | Stacked accordion, filled card style | + +### Authoring syntax + +Open a fenced block with the language tag `mdcms <type>`. The body is YAML with a single top-level key `items:`, whose value is a list of item objects. + +~~~markdown +```mdcms tab-underline +items: + - title: Install + default: selected + content: | + Install with `npm i mdcms` or `pnpm add mdcms`. + - title: Configure + content: | + Drop a `mdcms.config.yaml` next to your content folder. + - title: Deploy + content: | + Any static host. The build emits plain HTML. +``` +~~~ + +### Per-item keys + +| Key | Required | Type | Notes | +|---|---|---|---| +| `title` | yes | plain string | Label shown on the tab button or accordion header. Plain text only — no Markdown. | +| `content` | yes | Markdown block | Body content. Use the YAML literal block scalar (`\|`) for multi-line Markdown. Rendered with the same pipeline as the surrounding page (GFM, syntax highlighting, internal links). | +| `default` | no | string | **Tabs:** `selected` marks the tab that is open on load; if no item has `selected`, the first item is used. `notselected` (or omitting the key) leaves the tab inactive. Exactly one tab should be `selected`. **Accordions:** `open` makes the item expanded on load; `closed` (or omitting) leaves it collapsed. Any number of accordion items may be `open`. | +| `title-style` | no | string | Heading level for screen readers and external TOC tools. One of `"#"`, `"##"`, `"###"`, `"####"`, `"#####"`, `"######"`, or `""` (default). Visual size is always fixed by the component — this only changes the underlying ARIA role and level. Use a value when you want the item to be picked up as a heading by assistive technology. | + +### Examples + +**Tabs — underline (default)** + +~~~markdown +```mdcms tab +items: + - title: npm + default: selected + content: | + ```bash + npm install mdcms + ``` + - title: pnpm + content: | + ```bash + pnpm add mdcms + ``` + - title: yarn + content: | + ```bash + yarn add mdcms + ``` +``` +~~~ + +**Tabs — filled chips** + +~~~markdown +```mdcms tab-filled +items: + - title: Overview + default: selected + content: | + MD-CMS is a markdown-based static site system with no build step. + - title: Features + content: | + - Sidebar navigation + - Full-text search + - PWA + offline support + - Dark / light theme +``` +~~~ + +**Accordion — underline (default)** + +~~~markdown +```mdcms accordion +items: + - title: What is MD-CMS? + default: open + content: | + A single-file browser renderer. No build pipeline, no compilation, + no server required. + - title: How do I install it? + content: | + Run `pip install mdcms` or download a binary from the GitHub releases page. + - title: Does it work offline? + content: | + Yes — run `mdcms fetch-deps` to bundle vendor assets locally, then enable + `pwa: yes` in `config.yml` for full offline support. +``` +~~~ + +**Accordion — filled cards** + +~~~markdown +```mdcms accordion-filled +items: + - title: Can I use custom themes? + default: open + content: | + Yes. Create a `theme.yml` and reference it with `theme: theme.yml` in + `config.yml`. The theme controls colours, fonts, and layout. + - title: title-style example + title-style: "##" + content: | + This header is announced as an `<h2>` to screen readers, even though + its visual size is set by the accordion component. +``` +~~~ + +### How the appearance adapts to themes + +The components derive their fill colours and bar/border colours from the active theme at runtime. No new keys in `config.yml` or `theme.yml` are needed. + +**Bold themes** (nav background is visually distinct from the page — e.g. a dark sidebar on a light page, or a coloured nav like red or navy): filled tabs and accordion headers use the nav background colour as their fill; the bar/border uses the nav colour. This makes the components look like an extension of the sidebar chrome. + +**Subtle themes** (nav background is almost identical to the page — e.g. both near-white or both near-dark): filled tabs use a light tint of the accent colour; the bar and border use the accent colour directly. This keeps the components visible without a strong nav background to borrow from. + +The switch between bold and subtle is automatic. The algorithm uses HSL chroma (`S × (1−|2L−1|)`) rather than raw HSL saturation, which would give false "bold" readings for near-white or near-black nav backgrounds. + +--- + ## `mdcms build` patches `<title>` with sitename `mdcms build` now rewrites the `<title>` tag in `index.html` with the value of `sitename` from `config.yml`. Previously the tag was hardcoded (`MD-CMS`) in older templates, or blank in the starter template, so link previews in WhatsApp, Slack, and other crawlers that read static HTML showed the wrong name. From a4031bc008cad67bff41ecbf8ec44d6b444bb22b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 21 May 2026 16:41:45 +0000 Subject: [PATCH 17/19] docs: remind Claude to fetch remote branches before assuming one doesn't exist https://claude.ai/code/session_01SFMh7PDxJjvvo5dYbCCFFs --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 90afbd2..cad1d52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,8 @@ Only two branches exist in this repository: **`main`** and **`development`**. No In practice: check out `development`, do the work, push to `development`, PR `development` → `main` when ready to release. +**When a branch isn't visible locally:** always run `git fetch origin <branch-name>` before concluding a branch doesn't exist. Never create a new branch if the user names one — fetch it from the remote first. + ## Unreleased changelog `docs/unreleased.md` is a living document that tracks every fix or feature on `development` that has not yet been merged to `main`. Keep it current: whenever a change lands on `development`, add or update an entry in `unreleased.md` in the same commit (or a follow-up commit to `development`). When a batch of changes is merged to `main` and released, clear the entries that were released and leave the file in place for the next round of work. From ee3a967b86957727a3063e9a8ce56e658817988a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 21 May 2026 16:56:44 +0000 Subject: [PATCH 18/19] docs: add tabs and accordion reference to reference-pages.md Documents all four block types (tab-underline, tab-filled, accordion-underline, accordion-filled and their aliases) with per-item key tables and worked examples, in the same style as the existing callout/toc/posts-* sections. https://claude.ai/code/session_01SFMh7PDxJjvvo5dYbCCFFs --- docs/reference-pages.md | 86 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/docs/reference-pages.md b/docs/reference-pages.md index 6598ad9..287a336 100644 --- a/docs/reference-pages.md +++ b/docs/reference-pages.md @@ -167,6 +167,92 @@ paginate: yes # Pagination mode: --- +### Tabs — `tab-underline`, `tab-filled`, `tab` + +A horizontal tab strip with a single visible content panel. The active tab is set with `default: selected`; if no item carries that value the first item is selected automatically. + +| Tag name | Appearance | +|---|---| +| `tab-underline` | Labels in a row; active tab marked with a 2 px underline in the accent colour. | +| `tab` | Alias for `tab-underline`. | +| `tab-filled` | Each label is a chip with a filled background; active chip inverts to the page background with an accent border. | + +The body of the block is YAML. It must start with `items:` followed by a list of item objects. + +````markdown +```mdcms tab-underline +items: + - title: npm + default: selected + content: | + ```bash + npm install mdcms + ``` + - title: pnpm + content: | + ```bash + pnpm add mdcms + ``` + - title: yarn + content: | + ```bash + yarn add mdcms + ``` +``` +```` + +**Per-item keys:** + +| Key | Required | Notes | +|---|---|---| +| `title` | yes | Label on the tab button. Plain text only. | +| `content` | yes | Tab panel body. Full Markdown, use `\|` for multi-line. | +| `default` | no | `selected` — open on load. If no item is `selected`, the first item is used. | +| `title-style` | no | Heading level for screen readers. One of `"#"` … `"######"` or `""` (default). Does not affect visual size. | + +--- + +### Accordions — `accordion-underline`, `accordion-filled`, `accordion` + +Stacked collapsible items. Each item has a clickable header and a body that expands below it. Any number of items can be open simultaneously. + +| Tag name | Appearance | +|---|---| +| `accordion-underline` | Header separated from the content by a 2 px bar in the accent or nav colour; open content has a matching 1 px border on three sides. | +| `accordion` | Alias for `accordion-underline`. | +| `accordion-filled` | Closed header is a filled chip; when open the item becomes a single bordered card with the header fill at the top and the page background below. | + +````markdown +```mdcms accordion +items: + - title: What is MD-CMS? + default: open + content: | + A single-file browser renderer. No build pipeline, no compilation, + no server required. + - title: How do I install it? + content: | + Run `pip install mdcms` or download a binary from the GitHub releases page. + - title: Does it work offline? + content: | + Yes — run `mdcms fetch-deps` to bundle vendor assets locally, then enable + `pwa: yes` in `config.yml` for full offline support. +``` +```` + +**Per-item keys:** + +| Key | Required | Notes | +|---|---|---| +| `title` | yes | Header label. Plain text only. | +| `content` | yes | Body shown when expanded. Full Markdown, use `\|` for multi-line. | +| `default` | no | `open` — expanded on load. `closed` or omitted — collapsed. Multiple items may be `open`. | +| `title-style` | no | Heading level for screen readers. One of `"#"` … `"######"` or `""` (default). Does not affect visual size. | + +**How the colour adapts to themes:** The bar/border colour and the chip fill are derived automatically from the active theme. On themes where the sidebar background is visually distinct from the page (dark nav on a light page, or a coloured nav), the components use the nav colour as their fill. On subtle themes where sidebar and page backgrounds are near-identical, the accent colour is used instead. No per-theme config is needed. + +--- + ## Markdown features Standard CommonMark plus GFM (GitHub-flavoured) extensions: From 2e2fd2093f5e266135abba2ce675aee75879fca1 Mon Sep 17 00:00:00 2001 From: kbenestad <kristian@benestad.net> Date: Thu, 21 May 2026 23:57:51 +0700 Subject: [PATCH 19/19] Add files via upload --- themes/Operating Systems/os-adwaita.yaml | 76 ++++++++++++++++ themes/Operating Systems/os-aero.yaml | 82 ++++++++++++++++++ themes/Operating Systems/os-amiga.yaml | 82 ++++++++++++++++++ themes/Operating Systems/os-beos.yaml | 80 +++++++++++++++++ themes/Operating Systems/os-breeze.yaml | 86 +++++++++++++++++++ themes/Operating Systems/os-chromeos.yaml | 85 ++++++++++++++++++ .../os-cupertino-graphite.yaml | 72 ++++++++++++++++ themes/Operating Systems/os-cupertino.yaml | 85 ++++++++++++++++++ themes/Operating Systems/os-elementary.yaml | 81 +++++++++++++++++ themes/Operating Systems/os-fluent-dark.yaml | 71 +++++++++++++++ themes/Operating Systems/os-fluent.yaml | 81 +++++++++++++++++ themes/Operating Systems/os-ios.yaml | 80 +++++++++++++++++ themes/Operating Systems/os-material-you.yaml | 80 +++++++++++++++++ themes/Operating Systems/os-nextstep.yaml | 84 ++++++++++++++++++ themes/Operating Systems/os-pop.yaml | 79 +++++++++++++++++ themes/Operating Systems/os-system-7.yaml | 84 ++++++++++++++++++ 16 files changed, 1288 insertions(+) create mode 100644 themes/Operating Systems/os-adwaita.yaml create mode 100644 themes/Operating Systems/os-aero.yaml create mode 100644 themes/Operating Systems/os-amiga.yaml create mode 100644 themes/Operating Systems/os-beos.yaml create mode 100644 themes/Operating Systems/os-breeze.yaml create mode 100644 themes/Operating Systems/os-chromeos.yaml create mode 100644 themes/Operating Systems/os-cupertino-graphite.yaml create mode 100644 themes/Operating Systems/os-cupertino.yaml create mode 100644 themes/Operating Systems/os-elementary.yaml create mode 100644 themes/Operating Systems/os-fluent-dark.yaml create mode 100644 themes/Operating Systems/os-fluent.yaml create mode 100644 themes/Operating Systems/os-ios.yaml create mode 100644 themes/Operating Systems/os-material-you.yaml create mode 100644 themes/Operating Systems/os-nextstep.yaml create mode 100644 themes/Operating Systems/os-pop.yaml create mode 100644 themes/Operating Systems/os-system-7.yaml diff --git a/themes/Operating Systems/os-adwaita.yaml b/themes/Operating Systems/os-adwaita.yaml new file mode 100644 index 0000000..ab8907d --- /dev/null +++ b/themes/Operating Systems/os-adwaita.yaml @@ -0,0 +1,76 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-adwaita +# GNOME / Adwaita feel. Warm near-white paper, soft window-bg chrome, +# the familiar Adwaita blue accent. Cantarell typeface throughout. + +# ────────────────────────────────── +# Colours — based on published libadwaita tokens +# light: window_bg_color #fafafa, view_bg #ffffff, accent #1c71d8 (blue 4), +# fg ~rgba(0,0,0,0.8), dim ~rgba(0,0,0,0.55) +# dark: window_bg_color #242424, view_bg #1e1e1e, accent #78aeed (blue 1) +# ────────────────────────────────── +light: + accent: "#1C71D8" + background: "#FFFFFF" + nav-background: "#FAFAFA" + text: "#202020" + text-muted: "#5E5C64" + +dark: + accent: "#78AEED" + background: "#1E1E1E" + nav-background: "#242424" + text: "#FFFFFF" + text-muted: "#C0BFBC" + +# ────────────────────────────────── +# Semantic colours — Adwaita "named colors": green-3, yellow-5, red-3 +# ────────────────────────────────── +colours-semantic: + info: "#1C71D8" + warning: "#E5A50A" + success: "#26A269" + error: "#C01C28" + +colours-semantic-dark: + info: "#78AEED" + warning: "#F8E45C" + success: "#57E389" + error: "#F66151" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#1C71D8" + background-colour: "#1C71D8" + warning: + icon: warning + primary-colour: "#E5A50A" + background-colour: "#E5A50A" + success: + icon: success + primary-colour: "#26A269" + background-colour: "#26A269" + error: + icon: error + primary-colour: "#C01C28" + background-colour: "#C01C28" + +# ────────────────────────────────── +# Typography +# Cantarell is GNOME's UI typeface — humanist sans, slightly tall x-height. +# Available on Google Fonts. +# ────────────────────────────────── +font-body: "google:Cantarell:400" +font-heading: "google:Cantarell:700" +font-size: 1.00 +line-height: 1.65 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-aero.yaml b/themes/Operating Systems/os-aero.yaml new file mode 100644 index 0000000..57bddd5 --- /dev/null +++ b/themes/Operating Systems/os-aero.yaml @@ -0,0 +1,82 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-aero +# Windows Vista / 7 "Aero Glass" feel. Pale glass-tinted blue chrome, +# translucent sidebar vibes, bright sky-blue accent. The desktop your +# laptop sweated to render in 2009. +# +# Colours approximated from the default Aero theme palette: +# accent (taskbar / button glow) #1A78D4 +# glass tint #B8D6F0 (frosted blue) +# window face #F0F4F9 +# text #1B1B1B +# Aero Dark / "Aero Black" variant uses the same accent over near-black. + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#1A78D4" + background: "#F4F9FE" + nav-background: "#B8D6F0" + text: "#1B1B1B" + text-muted: "#525E6E" + +dark: + accent: "#4FC3F7" + background: "#0F1A2A" + nav-background: "#1A2A40" + text: "#EAF2FC" + text-muted: "#8FA8C4" + +# ────────────────────────────────── +# Semantic colours — Vista/7 standard hues +# ────────────────────────────────── +colours-semantic: + info: "#1A78D4" + warning: "#E59400" + success: "#1E8C3F" + error: "#C42B1C" + +colours-semantic-dark: + info: "#4FC3F7" + warning: "#FFC74A" + success: "#7AD18F" + error: "#FF7A7A" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#1A78D4" + background-colour: "#1A78D4" + warning: + icon: warning + primary-colour: "#E59400" + background-colour: "#E59400" + success: + icon: success + primary-colour: "#1E8C3F" + background-colour: "#1E8C3F" + error: + icon: error + primary-colour: "#C42B1C" + background-colour: "#C42B1C" + +# ────────────────────────────────── +# Typography +# Inter portable default. Preferred: Segoe UI (Vista/7 default — first +# Microsoft OS to ship it). Open Segoe-metric-compatible alternative: +# "Selawik". Drop your TTFs in /fonts and swap font-body / font-heading. +# ────────────────────────────────── +font-body: "bunny:Inter:400" +font-heading: "bunny:Inter:600" +font-size: 1.00 +line-height: 1.55 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-amiga.yaml b/themes/Operating Systems/os-amiga.yaml new file mode 100644 index 0000000..b802ecc --- /dev/null +++ b/themes/Operating Systems/os-amiga.yaml @@ -0,0 +1,82 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-amiga +# Workbench 1.3 revival. Iconic blue/orange/white/black on Workbench grey. +# Pixel-screen energy. The most idiosyncratic theme in the set. +# +# Original Workbench 1.x palette (4 colours, hardware-fixed): +# #0055AA blue (window chrome, background) +# #FFFFFF white +# #000000 black +# #FF8800 orange (highlights) +# 2.x onward added the warm grey #AAAAAA. + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#FF8800" + background: "#FFFFFF" + nav-background: "#0055AA" + text: "#000000" + text-muted: "#555555" + +dark: + accent: "#FF8800" + background: "#0055AA" + nav-background: "#003D7A" + text: "#FFFFFF" + text-muted: "#AAC4E0" + +# ────────────────────────────────── +# Semantic colours +# ────────────────────────────────── +colours-semantic: + info: "#0055AA" + warning: "#FF8800" + success: "#00AA55" + error: "#CC0000" + +colours-semantic-dark: + info: "#7FB2E0" + warning: "#FFB04A" + success: "#7FD9A4" + error: "#FF6B6B" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#0055AA" + background-colour: "#0055AA" + warning: + icon: warning + primary-colour: "#FF8800" + background-colour: "#FF8800" + success: + icon: success + primary-colour: "#00AA55" + background-colour: "#00AA55" + error: + icon: error + primary-colour: "#CC0000" + background-colour: "#CC0000" + +# ────────────────────────────────── +# Typography +# VT323 portable default for the pixel-screen feel. +# Preferred for true Workbench-fidelity: "Topaz" or "Topaz New" (free +# pixel font replicas of the Amiga system font, widely available as TTF). +# For a more readable modern take, swap to "bunny:IBM Plex Mono:400". +# ────────────────────────────────── +font-body: "google:VT323:400" +font-heading: "google:VT323:400" +font-size: 1.15 +line-height: 1.45 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 76em +nav-width: 20em diff --git a/themes/Operating Systems/os-beos.yaml b/themes/Operating Systems/os-beos.yaml new file mode 100644 index 0000000..134bf5f --- /dev/null +++ b/themes/Operating Systems/os-beos.yaml @@ -0,0 +1,80 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-beos +# BeOS / Haiku revival. Iconic yellow window tab, cream paper, +# navy text. The friendly weird desktop of 1996 that won't quit. +# +# Colours from Haiku's default "Beige" palette: +# panel background #DCDCDC +# document-tab yellow #FFCB00 +# text #000000 +# accent (link/button) #336699 navy + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#336699" + background: "#F8F8E8" + nav-background: "#FFCB00" + text: "#000000" + text-muted: "#4A4A3E" + +dark: + accent: "#FFCB00" + background: "#1A1A14" + nav-background: "#2A2515" + text: "#F8F8E8" + text-muted: "#A89E70" + +# ────────────────────────────────── +# Semantic colours +# ────────────────────────────────── +colours-semantic: + info: "#336699" + warning: "#CC7700" + success: "#3F8F3F" + error: "#B22222" + +colours-semantic-dark: + info: "#7FB2E0" + warning: "#FFCB00" + success: "#86C58B" + error: "#E07A7A" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#336699" + background-colour: "#336699" + warning: + icon: warning + primary-colour: "#CC7700" + background-colour: "#CC7700" + success: + icon: success + primary-colour: "#3F8F3F" + background-colour: "#3F8F3F" + error: + icon: error + primary-colour: "#B22222" + background-colour: "#B22222" + +# ────────────────────────────────── +# Typography +# Noto Sans is the portable default and Haiku's actual UI font. +# Preferred (BeOS original): Swis721 BT / "Be Sans" (proprietary, paid). +# DejaVu Sans is a very close free alternative. +# ────────────────────────────────── +font-body: "bunny:Noto Sans:400" +font-heading: "bunny:Noto Sans:700" +font-size: 1.00 +line-height: 1.55 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-breeze.yaml b/themes/Operating Systems/os-breeze.yaml new file mode 100644 index 0000000..9160913 --- /dev/null +++ b/themes/Operating Systems/os-breeze.yaml @@ -0,0 +1,86 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-breeze +# KDE Plasma / Breeze feel. Cool neutral chrome, the famous Plasma blue +# accent. Breeze is intentionally subtle — light, low-saturation, with +# just a touch of cool grey. +# +# Colours from KDE's Breeze stylesheet (qss + colour scheme): +# accent (Highlight) #3DAEE9 +# view-background-color #FCFCFC +# window-background-color #EFF0F1 +# foreground (Text) #232629 +# foreground-inactive #7F8C8D +# Breeze Dark: +# view-background-color #1B1E20 +# window-background-color #232629 +# foreground #FCFCFC + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#3DAEE9" + background: "#FCFCFC" + nav-background: "#EFF0F1" + text: "#232629" + text-muted: "#7F8C8D" + +dark: + accent: "#3DAEE9" + background: "#1B1E20" + nav-background: "#232629" + text: "#FCFCFC" + text-muted: "#A1A9B1" + +# ────────────────────────────────── +# Semantic colours — Breeze "positive / neutral / negative" tones +# ────────────────────────────────── +colours-semantic: + info: "#3DAEE9" + warning: "#F67400" + success: "#27AE60" + error: "#DA4453" + +colours-semantic-dark: + info: "#61C1F0" + warning: "#F8A04A" + success: "#56C883" + error: "#ED7077" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#3DAEE9" + background-colour: "#3DAEE9" + warning: + icon: warning + primary-colour: "#F67400" + background-colour: "#F67400" + success: + icon: success + primary-colour: "#27AE60" + background-colour: "#27AE60" + error: + icon: error + primary-colour: "#DA4453" + background-colour: "#DA4453" + +# ────────────────────────────────── +# Typography +# Noto Sans is the portable default and KDE's current UI font. +# Preferred (classic Plasma 4 era): "Oxygen Sans" — open SIL-licensed, +# available on Google Fonts as "Oxygen". +# ────────────────────────────────── +font-body: "bunny:Noto Sans:400" +font-heading: "bunny:Noto Sans:600" +font-size: 1.00 +line-height: 1.6 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-chromeos.yaml b/themes/Operating Systems/os-chromeos.yaml new file mode 100644 index 0000000..58d45ba --- /dev/null +++ b/themes/Operating Systems/os-chromeos.yaml @@ -0,0 +1,85 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-chromeos +# ChromeOS feel. Bright white shelf, soft Google-grey surface, +# Google Blue accent. Material-rooted but its own dialect. +# +# Colours from Google's public ChromeOS / Material reference: +# Google Blue 600 #1A73E8 (accent light) +# Google Blue 200 #8AB4F8 (accent dark) +# Surface #FFFFFF / #202124 +# Surface variant #F1F3F4 / #292A2D +# On-surface #202124 / #E8EAED + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#1A73E8" + background: "#FFFFFF" + nav-background: "#F1F3F4" + text: "#202124" + text-muted: "#5F6368" + +dark: + accent: "#8AB4F8" + background: "#202124" + nav-background: "#292A2D" + text: "#E8EAED" + text-muted: "#9AA0A6" + +# ────────────────────────────────── +# Semantic colours — Google standard hues +# Green 700 #1E8E3E / Green 300 #81C995 +# Yellow 700 #F29900 / Yellow 300 #FDD663 +# Red 600 #D93025 / Red 300 #F28B82 +# ────────────────────────────────── +colours-semantic: + info: "#1A73E8" + warning: "#F29900" + success: "#1E8E3E" + error: "#D93025" + +colours-semantic-dark: + info: "#8AB4F8" + warning: "#FDD663" + success: "#81C995" + error: "#F28B82" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#1A73E8" + background-colour: "#1A73E8" + warning: + icon: warning + primary-colour: "#F29900" + background-colour: "#F29900" + success: + icon: success + primary-colour: "#1E8E3E" + background-colour: "#1E8E3E" + error: + icon: error + primary-colour: "#D93025" + background-colour: "#D93025" + +# ────────────────────────────────── +# Typography +# Roboto is the portable default and ChromeOS's body font. +# Preferred for headings: "Google Sans" (proprietary, restricted). +# Open near-equivalent for Google Sans display: "Product Sans" +# — also restricted; use Roboto for both and you'll be fine. +# ────────────────────────────────── +font-body: "bunny:Roboto:400" +font-heading: "bunny:Roboto:500" +font-size: 1.00 +line-height: 1.55 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-cupertino-graphite.yaml b/themes/Operating Systems/os-cupertino-graphite.yaml new file mode 100644 index 0000000..9edd420 --- /dev/null +++ b/themes/Operating Systems/os-cupertino-graphite.yaml @@ -0,0 +1,72 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-cupertino-graphite +# Mac desktop, Graphite accent variant — for people who switch the system +# tint to "Graphite" because they're serious. Pure neutral chrome. + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#6E6E73" + background: "#FFFFFF" + nav-background: "#F2F2F7" + text: "#1D1D1F" + text-muted: "#6E6E73" + +dark: + accent: "#98989D" + background: "#1E1E1E" + nav-background: "#2C2C2E" + text: "#F5F5F7" + text-muted: "#98989D" + +# ────────────────────────────────── +# Semantic colours +# ────────────────────────────────── +colours-semantic: + info: "#6E6E73" + warning: "#FF9500" + success: "#34C759" + error: "#FF3B30" + +colours-semantic-dark: + info: "#98989D" + warning: "#FF9F0A" + success: "#30D158" + error: "#FF453A" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#6E6E73" + background-colour: "#6E6E73" + warning: + icon: warning + primary-colour: "#FF9500" + background-colour: "#FF9500" + success: + icon: success + primary-colour: "#34C759" + background-colour: "#34C759" + error: + icon: error + primary-colour: "#FF3B30" + background-colour: "#FF3B30" + +# ────────────────────────────────── +# Typography +# Inter portable default. Preferred: SF Pro Text / SF Pro Display. +# ────────────────────────────────── +font-body: "bunny:Inter:400" +font-heading: "bunny:Inter:600" +font-size: 1.00 +line-height: 1.55 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-cupertino.yaml b/themes/Operating Systems/os-cupertino.yaml new file mode 100644 index 0000000..5eb1248 --- /dev/null +++ b/themes/Operating Systems/os-cupertino.yaml @@ -0,0 +1,85 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-cupertino +# Mac desktop feel. Bright white paper, light-platinum sidebar, +# vivid system blue accent. Dark mode goes near-black with brighter blue. +# +# Colours from Apple's publicly-documented system colour palette +# (developer.apple.com → Human Interface Guidelines → Color): +# systemBlue light #007AFF dark #0A84FF +# secondarySystemBackground (light) #F2F2F7 +# systemBackground (dark) #000000 +# secondarySystemBackground (dark) #1C1C1E + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#007AFF" + background: "#FFFFFF" + nav-background: "#F2F2F7" + text: "#1D1D1F" + text-muted: "#6E6E73" + +dark: + accent: "#0A84FF" + background: "#1E1E1E" + nav-background: "#2C2C2E" + text: "#F5F5F7" + text-muted: "#98989D" + +# ────────────────────────────────── +# Semantic colours — Apple system colours (light / dark) +# green #34C759 / #30D158 +# orange #FF9500 / #FF9F0A +# red #FF3B30 / #FF453A +# ────────────────────────────────── +colours-semantic: + info: "#007AFF" + warning: "#FF9500" + success: "#34C759" + error: "#FF3B30" + +colours-semantic-dark: + info: "#0A84FF" + warning: "#FF9F0A" + success: "#30D158" + error: "#FF453A" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#007AFF" + background-colour: "#007AFF" + warning: + icon: warning + primary-colour: "#FF9500" + background-colour: "#FF9500" + success: + icon: success + primary-colour: "#34C759" + background-colour: "#34C759" + error: + icon: error + primary-colour: "#FF3B30" + background-colour: "#FF3B30" + +# ────────────────────────────────── +# Typography +# Inter is the portable default — close metrics to SF. +# Preferred on Apple platforms: SF Pro Text (body), SF Pro Display (headings). +# Drop your own TTFs in /fonts and change font-body / font-heading to +# "local:SF Pro Text:400" etc. +# ────────────────────────────────── +font-body: "bunny:Inter:400" +font-heading: "bunny:Inter:600" +font-size: 1.00 +line-height: 1.55 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-elementary.yaml b/themes/Operating Systems/os-elementary.yaml new file mode 100644 index 0000000..6aec4d5 --- /dev/null +++ b/themes/Operating Systems/os-elementary.yaml @@ -0,0 +1,81 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-elementary +# elementary OS feel. Calm paper, "Slate" silver chrome, +# Blueberry-blue accent. Inter is their actual UI typeface (Inter Variable). +# +# Colours from elementary's published Stylesheet (Granite/Pantheon): +# Blueberry 500 #3689E6 (accent) +# Slate 100 #F4F4F4 / Slate 700 #333333 +# Strawberry/Lime/Banana/Cherry are the named semantic palette. + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#3689E6" + background: "#FAFAFA" + nav-background: "#F4F4F4" + text: "#333333" + text-muted: "#7E8087" + +dark: + accent: "#64BAFF" + background: "#1A1A1A" + nav-background: "#262626" + text: "#FFFFFF" + text-muted: "#A6A6A6" + +# ────────────────────────────────── +# Semantic colours — elementary named palette +# Lime 500 #68B723 success +# Banana 500 #F9C440 warning +# Strawberry 500 #C6262E error +# Blueberry 500 #3689E6 info +# ────────────────────────────────── +colours-semantic: + info: "#3689E6" + warning: "#F9C440" + success: "#68B723" + error: "#C6262E" + +colours-semantic-dark: + info: "#64BAFF" + warning: "#FFD66B" + success: "#9BDB4D" + error: "#E14852" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#3689E6" + background-colour: "#3689E6" + warning: + icon: warning + primary-colour: "#F9C440" + background-colour: "#F9C440" + success: + icon: success + primary-colour: "#68B723" + background-colour: "#68B723" + error: + icon: error + primary-colour: "#C6262E" + background-colour: "#C6262E" + +# ────────────────────────────────── +# Typography +# Inter is elementary's actual UI typeface — used as-is. +# ────────────────────────────────── +font-body: "bunny:Inter:400" +font-heading: "bunny:Inter:600" +font-size: 1.00 +line-height: 1.55 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-fluent-dark.yaml b/themes/Operating Systems/os-fluent-dark.yaml new file mode 100644 index 0000000..0c834a3 --- /dev/null +++ b/themes/Operating Systems/os-fluent-dark.yaml @@ -0,0 +1,71 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-fluent-dark +# Windows 11 dark mica. Same accent system, dark-first defaults. + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#005FB8" + background: "#202020" + nav-background: "#2C2C2C" + text: "#FFFFFF" + text-muted: "#C7C7C7" + +dark: + accent: "#60CDFF" + background: "#1A1A1A" + nav-background: "#202020" + text: "#FFFFFF" + text-muted: "#C7C7C7" + +# ────────────────────────────────── +# Semantic colours +# ────────────────────────────────── +colours-semantic: + info: "#60CDFF" + warning: "#FCE100" + success: "#6CCB5F" + error: "#FF99A4" + +colours-semantic-dark: + info: "#60CDFF" + warning: "#FCE100" + success: "#6CCB5F" + error: "#FF99A4" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#60CDFF" + background-colour: "#60CDFF" + warning: + icon: warning + primary-colour: "#FCE100" + background-colour: "#FCE100" + success: + icon: success + primary-colour: "#6CCB5F" + background-colour: "#6CCB5F" + error: + icon: error + primary-colour: "#FF99A4" + background-colour: "#FF99A4" + +# ────────────────────────────────── +# Typography +# Inter portable default. Preferred: Segoe UI Variable / Selawik. +# ────────────────────────────────── +font-body: "bunny:Inter:400" +font-heading: "bunny:Inter:600" +font-size: 1.00 +line-height: 1.55 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-fluent.yaml b/themes/Operating Systems/os-fluent.yaml new file mode 100644 index 0000000..5ade995 --- /dev/null +++ b/themes/Operating Systems/os-fluent.yaml @@ -0,0 +1,81 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-fluent +# Windows 11 / Fluent feel. Cool near-white "Mica" paper, light gray sidebar, +# Windows accent blue. Dark mode uses the dark mica neutrals. +# +# Colours from Microsoft's public Fluent 2 design tokens: +# accent (light): #005FB8 accent (dark): #60CDFF +# neutralBackground1 light #F9F9F9 / sidebar #F3F3F3 +# neutralBackground1 dark #202020 / sidebar #2C2C2C +# neutralForeground1 light #1A1A1A / dark #FFFFFF + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#005FB8" + background: "#F9F9F9" + nav-background: "#F3F3F3" + text: "#1A1A1A" + text-muted: "#5C5C5C" + +dark: + accent: "#60CDFF" + background: "#202020" + nav-background: "#2C2C2C" + text: "#FFFFFF" + text-muted: "#C7C7C7" + +# ────────────────────────────────── +# Semantic colours — Fluent persona / shared colours +# ────────────────────────────────── +colours-semantic: + info: "#005FB8" + warning: "#9D5D00" + success: "#107C10" + error: "#C42B1C" + +colours-semantic-dark: + info: "#60CDFF" + warning: "#FCE100" + success: "#6CCB5F" + error: "#FF99A4" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#005FB8" + background-colour: "#005FB8" + warning: + icon: warning + primary-colour: "#9D5D00" + background-colour: "#9D5D00" + success: + icon: success + primary-colour: "#107C10" + background-colour: "#107C10" + error: + icon: error + primary-colour: "#C42B1C" + background-colour: "#C42B1C" + +# ────────────────────────────────── +# Typography +# Inter portable default. Preferred on Windows: Segoe UI Variable Text +# (body) / Segoe UI Variable Display (headings). Open alternatives: +# "Selawik" or "Selawik Semilight" (Microsoft's Segoe-metric-compatible +# release). Drop TTFs in /fonts and swap to "local:Segoe UI Variable:400". +# ────────────────────────────────── +font-body: "bunny:Inter:400" +font-heading: "bunny:Inter:600" +font-size: 1.00 +line-height: 1.55 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-ios.yaml b/themes/Operating Systems/os-ios.yaml new file mode 100644 index 0000000..72a5692 --- /dev/null +++ b/themes/Operating Systems/os-ios.yaml @@ -0,0 +1,80 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-ios +# iPhone feel. Crisp white paper, grouped-table grey sidebar, +# iOS system blue. Dark mode goes true-black like the OLED dark mode. +# +# Colours from Apple's iOS system colour palette: +# systemBlue light #007AFF / dark #0A84FF +# systemBackground light #FFFFFF / dark #000000 +# secondarySystemBackground light #F2F2F7 / dark #1C1C1E +# label light #000000 / dark #FFFFFF +# secondaryLabel light #3C3C43 60% / dark #EBEBF5 60% + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#007AFF" + background: "#FFFFFF" + nav-background: "#F2F2F7" + text: "#000000" + text-muted: "#8E8E93" + +dark: + accent: "#0A84FF" + background: "#000000" + nav-background: "#1C1C1E" + text: "#FFFFFF" + text-muted: "#8E8E93" + +# ────────────────────────────────── +# Semantic colours — iOS system colours +# ────────────────────────────────── +colours-semantic: + info: "#007AFF" + warning: "#FF9500" + success: "#34C759" + error: "#FF3B30" + +colours-semantic-dark: + info: "#0A84FF" + warning: "#FF9F0A" + success: "#30D158" + error: "#FF453A" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#007AFF" + background-colour: "#007AFF" + warning: + icon: warning + primary-colour: "#FF9500" + background-colour: "#FF9500" + success: + icon: success + primary-colour: "#34C759" + background-colour: "#34C759" + error: + icon: error + primary-colour: "#FF3B30" + background-colour: "#FF3B30" + +# ────────────────────────────────── +# Typography +# Inter portable default. Preferred on iOS: SF Pro Text (body), +# SF Pro Display (headings ≥20pt), SF Pro Rounded for friendly UI. +# ────────────────────────────────── +font-body: "bunny:Inter:400" +font-heading: "bunny:Inter:600" +font-size: 1.00 +line-height: 1.5 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-material-you.yaml b/themes/Operating Systems/os-material-you.yaml new file mode 100644 index 0000000..048885e --- /dev/null +++ b/themes/Operating Systems/os-material-you.yaml @@ -0,0 +1,80 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-material-you +# Android / Material 3 feel. Tonal-palette neutrals built on the M3 +# baseline purple (#6750A4). Pale lavender paper, soft surface chrome. +# +# Colours from the Material 3 baseline scheme: +# primary light #6750A4 / dark #D0BCFF +# surface light #FEF7FF / dark #141218 +# surface-container-low light #F7F2FA / dark #1D1B20 +# on-surface light #1D1B20 / dark #E6E0E9 +# on-surface-variant light #49454F / dark #CAC4D0 + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#6750A4" + background: "#FEF7FF" + nav-background: "#F7F2FA" + text: "#1D1B20" + text-muted: "#49454F" + +dark: + accent: "#D0BCFF" + background: "#141218" + nav-background: "#1D1B20" + text: "#E6E0E9" + text-muted: "#CAC4D0" + +# ────────────────────────────────── +# Semantic colours — M3 baseline error + standard tertiary/green/yellow +# ────────────────────────────────── +colours-semantic: + info: "#6750A4" + warning: "#9A6700" + success: "#386A20" + error: "#B3261E" + +colours-semantic-dark: + info: "#D0BCFF" + warning: "#EFBE6E" + success: "#A6D388" + error: "#F2B8B5" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#6750A4" + background-colour: "#6750A4" + warning: + icon: warning + primary-colour: "#9A6700" + background-colour: "#9A6700" + success: + icon: success + primary-colour: "#386A20" + background-colour: "#386A20" + error: + icon: error + primary-colour: "#B3261E" + background-colour: "#B3261E" + +# ────────────────────────────────── +# Typography +# Roboto is the portable default and the Material default. +# Preferred: Roboto Flex (variable) or Google Sans for headings. +# ────────────────────────────────── +font-body: "bunny:Roboto:400" +font-heading: "bunny:Roboto:500" +font-size: 1.00 +line-height: 1.55 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-nextstep.yaml b/themes/Operating Systems/os-nextstep.yaml new file mode 100644 index 0000000..e458aef --- /dev/null +++ b/themes/Operating Systems/os-nextstep.yaml @@ -0,0 +1,84 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-nextstep +# NeXTSTEP / OPENSTEP revival. Cool 50% greys everywhere, jet-black +# title chrome, and the famous NeXT magenta as the accent. Heavy, +# considered, very 1989 Cube energy. +# +# Colours approximated from NeXTSTEP's 2-bit greyscale + colour passes: +# #555555 dark window chrome (title bars, scrollbar wells) +# #AAAAAA panel face (50% grey) +# #DDDDDD highlight +# #000000 ink +# #C72A86 NeXT magenta (used in logo + accents) + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#C72A86" + background: "#DDDDDD" + nav-background: "#555555" + text: "#000000" + text-muted: "#5A5A5A" + +dark: + accent: "#E579B5" + background: "#1A1A1A" + nav-background: "#000000" + text: "#DDDDDD" + text-muted: "#A0A0A0" + +# ────────────────────────────────── +# Semantic colours — kept restrained, in keeping with the grey-on-grey +# NeXTSTEP discipline. +# ────────────────────────────────── +colours-semantic: + info: "#3A6FA5" + warning: "#A06A00" + success: "#3E7A3E" + error: "#A02828" + +colours-semantic-dark: + info: "#88AED9" + warning: "#D9B36B" + success: "#86C58B" + error: "#E07A7A" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#3A6FA5" + background-colour: "#3A6FA5" + warning: + icon: warning + primary-colour: "#A06A00" + background-colour: "#A06A00" + success: + icon: success + primary-colour: "#3E7A3E" + background-colour: "#3E7A3E" + error: + icon: error + primary-colour: "#A02828" + background-colour: "#A02828" + +# ────────────────────────────────── +# Typography +# Inter portable default. NeXTSTEP used Helvetica system-wide. +# Preferred: "Helvetica Neue" (Apple system) — falls back to Inter. +# For the more brutalist OPENSTEP feel try "bunny:Helvetica:400" +# if you have it installed locally. +# ────────────────────────────────── +font-body: "bunny:Inter:400" +font-heading: "bunny:Inter:700" +font-size: 1.00 +line-height: 1.55 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 76em +nav-width: 20em diff --git a/themes/Operating Systems/os-pop.yaml b/themes/Operating Systems/os-pop.yaml new file mode 100644 index 0000000..7fd5b33 --- /dev/null +++ b/themes/Operating Systems/os-pop.yaml @@ -0,0 +1,79 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-pop +# Pop!_OS feel. Warm Cosmic-grey chrome, signature Pop orange accent. +# System76's GTK-rooted desktop with its own distinctive warmth. +# +# Colours from System76's public Pop palette: +# Pop Orange #FAA41A (primary accent) +# Cosmic Light bg #F2F2F2 / surface #FAFAFA +# Cosmic Dark bg #2D2D2D / surface #232323 +# Text light #181818 / dark #F2F2F2 + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#FAA41A" + background: "#FAFAFA" + nav-background: "#F2F2F2" + text: "#181818" + text-muted: "#5C5C5C" + +dark: + accent: "#FAA41A" + background: "#232323" + nav-background: "#2D2D2D" + text: "#F2F2F2" + text-muted: "#A8A8A8" + +# ────────────────────────────────── +# Semantic colours — Pop palette greens/yellows/reds with the warm cast +# ────────────────────────────────── +colours-semantic: + info: "#1B6091" + warning: "#FAA41A" + success: "#73C48F" + error: "#F15D22" + +colours-semantic-dark: + info: "#88B8DC" + warning: "#FFC664" + success: "#9BD7AF" + error: "#FF8A5C" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#1B6091" + background-colour: "#1B6091" + warning: + icon: warning + primary-colour: "#FAA41A" + background-colour: "#FAA41A" + success: + icon: success + primary-colour: "#73C48F" + background-colour: "#73C48F" + error: + icon: error + primary-colour: "#F15D22" + background-colour: "#F15D22" + +# ────────────────────────────────── +# Typography +# Fira Sans is the portable default and Pop!_OS's actual UI font. +# Available on Google Fonts. Pop also ships Fira Mono for code. +# ────────────────────────────────── +font-body: "bunny:Fira Sans:400" +font-heading: "bunny:Fira Sans:600" +font-size: 1.00 +line-height: 1.6 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 78em +nav-width: 20em diff --git a/themes/Operating Systems/os-system-7.yaml b/themes/Operating Systems/os-system-7.yaml new file mode 100644 index 0000000..dbd3729 --- /dev/null +++ b/themes/Operating Systems/os-system-7.yaml @@ -0,0 +1,84 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — os-system-7 +# Classic Macintosh System 7 / early Mac OS. Black on white with the +# Platinum-grey window chrome that arrived around System 7.5. Very flat, +# very calm, very monochrome. +# +# Colours: +# #FFFFFF paper (white) +# #DDDDDD Platinum window chrome +# #000000 ink (1-bit Mac heritage) +# #B0B0B0 shadow grey + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#000000" + background: "#FFFFFF" + nav-background: "#DDDDDD" + text: "#000000" + text-muted: "#555555" + +dark: + accent: "#FFFFFF" + background: "#1A1A1A" + nav-background: "#262626" + text: "#FFFFFF" + text-muted: "#A0A0A0" + +# ────────────────────────────────── +# Semantic colours — restrained, since System 7 was a 1-bit interface +# until colour Macs. Kept muted and "drawn-in-MacPaint". +# ────────────────────────────────── +colours-semantic: + info: "#000000" + warning: "#7A5A00" + success: "#1F5A1F" + error: "#8B0000" + +colours-semantic-dark: + info: "#FFFFFF" + warning: "#E5C36B" + success: "#7FB87F" + error: "#E08585" + +# ────────────────────────────────── +# Callout defaults +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#000000" + background-colour: "#000000" + warning: + icon: warning + primary-colour: "#7A5A00" + background-colour: "#7A5A00" + success: + icon: success + primary-colour: "#1F5A1F" + background-colour: "#1F5A1F" + error: + icon: error + primary-colour: "#8B0000" + background-colour: "#8B0000" + +# ────────────────────────────────── +# Typography +# Inter portable default. Preferred for true System 7 fidelity: +# "ChicagoFLF" (free Chicago revival, body) for headings +# "Geneva" (system) for body — or any free Geneva-alike like +# "Charcoal CY" or "ArkPixel". +# For an authentic 1-bit look, try a pixel font like "VT323". +# ────────────────────────────────── +font-body: "bunny:Inter:400" +font-heading: "bunny:Inter:700" +font-size: 1.00 +line-height: 1.6 + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 76em +nav-width: 20em