Compare commits

...

47 Commits

Author SHA1 Message Date
gitea-actions
74924ea06f Improve resource loader error reporting and thumbnail uploads
All checks were successful
Build Org Website / build (push) Successful in 44s
2026-08-24 10:14:46 +01:00
gitea-actions
4653a2caae Make Resource Loader server-backed and drop local metadata cache
All checks were successful
Build Org Website / build (push) Successful in 50s
2026-08-20 14:19:00 +01:00
gitea-actions
7a60d33610 Auto commit
All checks were successful
Build Org Website / build (push) Successful in 40s
2026-08-20 14:03:52 +01:00
gitea-actions
0b4a68b81b Auto commit
All checks were successful
Build Org Website / build (push) Successful in 39s
2026-08-20 11:33:47 +01:00
gitea-actions
65885dbb3a updates
All checks were successful
Build Org Website / build (push) Successful in 38s
2026-08-20 11:32:08 +01:00
gitea-actions
8dbeca7570 Refine resource loader status grouping and PDF import handling
All checks were successful
Build Org Website / build (push) Successful in 39s
2026-08-19 15:36:00 +01:00
gitea-actions
b8bb1944dd Add Resource Loader SPA and JS test harness
All checks were successful
Build Org Website / build (push) Successful in 38s
2026-08-19 15:03:41 +01:00
gitea-actions
75af3e1f0e Improve search button accessibility and refresh site metadata
All checks were successful
Build Org Website / build (push) Successful in 32s
2026-08-19 12:03:35 +01:00
gitea-actions
d12edea8de Remove obsolete generated assets and dead code
All checks were successful
Build Org Website / build (push) Successful in 43s
2026-08-19 10:53:30 +01:00
gitea-actions
2ee6575539 updates
All checks were successful
Build Org Website / build (push) Successful in 1m4s
2026-08-19 10:45:57 +01:00
gitea-actions
08516fda14 Serve Two Rivers modules with production MIME type
All checks were successful
Build Org Website / build (push) Successful in 45s
2026-08-11 13:40:14 +01:00
gitea-actions
262d943443 Fix Two Rivers production age gate
All checks were successful
Build Org Website / build (push) Successful in 44s
2026-07-31 14:15:42 +01:00
gitea-actions
4659edc658 Add standalone export for Two Rivers at Moonrise
All checks were successful
Build Org Website / build (push) Successful in 44s
2026-07-30 23:37:18 +01:00
gitea-actions
563d4b63cc Tune Princess Lima v2 boss balance and Sun Crystal aid
All checks were successful
Build Org Website / build (push) Successful in 37s
2026-07-30 15:36:59 +01:00
gitea-actions
6066f486de Refine Princess Lima for v2 presentation
All checks were successful
Build Org Website / build (push) Successful in 1m2s
2026-07-30 15:00:45 +01:00
gitea-actions
a6502b168d Polish Princess Lima map data and interaction feedback
All checks were successful
Build Org Website / build (push) Successful in 38s
2026-07-30 13:58:25 +01:00
gitea-actions
15b626ee0d Rebuild Princess Lima maps around the new visual atlas
All checks were successful
Build Org Website / build (push) Successful in 38s
2026-07-30 12:48:17 +01:00
gitea-actions
a0c78929b3 Refactor platform UI and data flow across core pages
All checks were successful
Build Org Website / build (push) Successful in 50s
2026-07-30 12:22:44 +01:00
gitea-actions
fe8acac707 Refactor org web platform and remove legacy code
All checks were successful
Build Org Website / build (push) Successful in 39s
2026-07-30 11:29:20 +01:00
gitea-actions
dc1ca5c00e Refine archive world spawn and interaction handling
All checks were successful
Build Org Website / build (push) Successful in 38s
2026-07-30 10:22:23 +01:00
gitea-actions
2f125f32ae Refine Archive World navigation and gate progression
All checks were successful
Build Org Website / build (push) Successful in 38s
2026-07-30 10:02:58 +01:00
gitea-actions
c06af38d80 Harden archive-world setup and refresh site links
All checks were successful
Build Org Website / build (push) Successful in 39s
2026-07-30 09:08:12 +01:00
gitea-actions
5b59f5745c Refactor org platform navigation and content flows
All checks were successful
Build Org Website / build (push) Successful in 38s
2026-07-29 21:42:31 +01:00
gitea-actions
7afc554f5d Refactor org web content and simplify shared UI
All checks were successful
Build Org Website / build (push) Successful in 44s
2026-07-29 15:17:17 +01:00
gitea-actions
db90e9f452 21
Some checks failed
Build Org Website / build (push) Failing after 2m44s
2026-07-21 12:08:21 +01:00
gitea-actions
8dd05d4d03 catchups
All checks were successful
Build Org Website / build (push) Successful in 38s
2026-07-15 18:53:38 +01:00
gitea-actions
67ab0d2ddb updates
All checks were successful
Build Org Website / build (push) Successful in 34s
2026-07-15 18:36:00 +01:00
gitea-actions
9d758f5254 Update hidden details [skip actions] 2026-07-15 01:00:35 +01:00
gitea-actions
4df4cd1055 update json
All checks were successful
Build Org Website / build (push) Successful in 36s
2026-07-14 12:38:11 +01:00
gitea-actions
2fb88916d5 docs: centralize repository documentation
All checks were successful
Build Org Website / build (push) Successful in 51s
2026-07-13 00:02:22 +01:00
gitea-actions
e82fdf1230 12
All checks were successful
Build Org Website / build (push) Successful in 44s
2026-07-12 23:49:58 +01:00
gitea-actions
f4b388d70b Update hidden details [skip actions] 2026-07-12 23:13:37 +01:00
gitea-actions
90c8321ba3 Update hidden details [skip actions] 2026-07-09 17:27:09 +01:00
gitea-actions
4c9294490e testing 3
All checks were successful
Build Org Website / build (push) Successful in 45s
2026-07-09 17:21:44 +01:00
gitea-actions
0d56f36a26 testing 2
All checks were successful
Build Org Website / build (push) Successful in 30s
2026-07-09 17:16:50 +01:00
gitea-actions
275a955a5f testing
All checks were successful
Build Org Website / build (push) Successful in 34s
2026-07-09 17:06:35 +01:00
gitea-actions
af79b439ce testing first memory x2
All checks were successful
Build Org Website / build (push) Successful in 33s
2026-07-09 16:50:48 +01:00
gitea-actions
17e02977d6 testing first memory x2
All checks were successful
Build Org Website / build (push) Successful in 36s
2026-07-09 16:49:49 +01:00
gitea-actions
9b259ac173 testing first memory
All checks were successful
Build Org Website / build (push) Successful in 38s
2026-07-09 16:46:11 +01:00
gitea-actions
2bb816b2ae change mode and fix build
All checks were successful
Build Org Website / build (push) Successful in 30s
2026-07-08 22:42:05 +01:00
gitea-actions
76a708e8fc Update hidden details [skip actions] 2026-07-08 22:38:19 +01:00
1883097397 Merge pull request 'zq/personalise' (#1) from zq/personalise into main
Some checks failed
Build Org Website / build (push) Failing after 29s
Reviewed-on: #1
2026-07-08 22:31:38 +01:00
gitea-actions
0db2151f92 removing author and cleaning js / css 2026-07-08 22:28:43 +01:00
gitea-actions
d0610a5907 removing stale rnote files 2026-07-08 22:17:11 +01:00
gitea-actions
53df5b46a3 seperating play 2026-07-08 22:14:41 +01:00
gitea-actions
b7f08ba46d seperating build 2026-07-08 22:09:29 +01:00
gitea-actions
832eb1a913 cleaning up 2026-07-08 21:50:06 +01:00
218 changed files with 24910 additions and 23197 deletions

View File

@@ -57,4 +57,4 @@ jobs:
API_KEY: ${{ secrets.API_KEY }} API_KEY: ${{ secrets.API_KEY }}
run: | run: |
cd /home/zaine/master-folder/org-platform/org_web cd /home/zaine/master-folder/org-platform/org_web
/snap/bin/pwsh -NoProfile -File ./gitea-build-monitor.ps1 /snap/bin/pwsh -NoProfile -File ./build-logs/gitea-build-monitor.ps1

4
.gitignore vendored
View File

@@ -1,7 +1,9 @@
.venv/ .venv/
node_modules/
assets/games/two-rivers/images/characters/*-source.png
output/ output/
.org-timestamps/ .org-timestamps/
backup/ backups/
__pycache__/ __pycache__/
*~ *~
.syncthing*.tmp .syncthing*.tmp

View File

@@ -1,4 +1,4 @@
.PHONY: all clean norm test author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help .PHONY: all clean norm test test-elisp test-js author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help
VENV := .venv VENV := .venv
PY := $(VENV)/bin/python PY := $(VENV)/bin/python
@@ -25,6 +25,12 @@ search: $(VENV)
test: test:
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" test $(MAKE) -C "$(AUTHOR_SERVICE_DIR)" test
test-elisp:
emacs -Q --batch -l lisp/tests/build-site-tests.el -f ert-run-tests-batch-and-exit
test-js:
node --test tests/*.test.js
$(VENV): $(VENV):
@echo "Generating virtual environment" @echo "Generating virtual environment"
python3 -m venv $(VENV) python3 -m venv $(VENV)
@@ -80,6 +86,8 @@ help:
@echo " make clean-venv - Remove venv directory" @echo " make clean-venv - Remove venv directory"
@echo " make norm - Move backup files" @echo " make norm - Move backup files"
@echo " make test - Run authoring server tests" @echo " make test - Run authoring server tests"
@echo " make test-elisp - Run Emacs Lisp build tests"
@echo " make test-js - Run browser-model JavaScript tests"
@echo " make author - Install and start the authoring UI service" @echo " make author - Install and start the authoring UI service"
@echo " make author-install - Install and enable the authoring UI service" @echo " make author-install - Install and enable the authoring UI service"
@echo " make author-stop - Stop the authoring UI service" @echo " make author-stop - Stop the authoring UI service"

412
README.md
View File

@@ -1,29 +1,405 @@
# Introduction # zxh / org-web
This repository contains code for the website https://zainezq.com, which serves as a central familial hub for me. This repository is the source and publishing pipeline for [zainezq.com](https://zainezq.com), a personal and family website built primarily from Emacs Org-mode files. It contains the written content, the Emacs Lisp build system, browser assets, generated indexes, playful interactive pages, and the Gitea Actions workflow that publishes the site.
The site is written using Emacs and Org-mode, and exported using the `org-publish` functionality. See the `build-site.el` script for details on how the site is built and published. This README is the central technical guide for the repository: start here for architecture, authoring, builds, frontend work, testing, and deployment.
## Local authoring UI ## System overview
The authoring UI now lives in `/home/zaine/master-folder/org-platform/authoring-service`. The application has three boundaries:
Run `make author` from either repository to install and start the user systemd service, then open `http://127.0.0.1:8765` to create or edit blog and post Org files from a browser. When deployed behind the reverse proxy, use `https://author.zainezq.com`. 1. **Site source (this repository):** Org/Markdown content, Emacs Lisp publishing code, CSS, JavaScript, images, and build tooling.
2. **Static output (**`output/`**):** generated HTML, copied assets, and `search-index.json`. This is the deployable website, not the source of truth.
3. **Runtime services (outside this repository):** the authoring service and the same-origin APIs used by interactive pages. The static site remains readable without them, but the relevant dynamic features will not work.
Useful service commands: ```mermaid
flowchart LR
Author["Author or authoring service"] --> Source["Org and Markdown source"]
Source --> Emacs["Emacs org-publish pipeline"]
Assets["CSS, JavaScript, images, fonts"] --> Emacs
Emacs --> HTML["output/: HTML and assets"]
HTML --> Search["Python search indexer"]
Search --> Deploy["Static site at zainezq.com"]
Deploy <-->|"same-origin /api requests"| APIs["Runtime APIs outside this repo"]
```
- `make author-restart` restarts the service and waits for the page API to respond ## Repository map
- `make author-stop` stops the service
- `make author-status` shows systemd status and the editable page count
- `make author-logs` shows recent journal logs
The editor writes normal `.org` pages with the metadata used by the publishing pipeline: | Path | Purpose | Source or generated? |
|----------------------------|------------------------------------------------------------|----------------------------------------------------------------|
| `index.org` | Home page source | Source |
| `blogs/` | Dated personal writing, grouped by year/month | Source, except generated list pages |
| `posts/` | Longer-lived posts; `posts/career/` has its own index | Source, except generated list pages |
| `home/` | Utility and dashboard-style pages | Source, except `categories.org` |
| `play/` | Interactive and experimental pages | Source |
| `rl/` | Server-synced Resource Loader SPA | Source |
| `lima/` | Org or Markdown pages with special attachment handling | Source, except `lima-list.org` |
| `assets/` | Browser code, styles, fonts, images, and content manifests | Mostly source; some manifests/scripts are generated externally |
| `lisp/` | Modular Emacs Lisp publishing system | Source |
| `lisp/tests/` | ERT tests for the publishing logic | Source |
| `tags/` | Per-tag Org pages created during a build | Generated |
| `recently-updated.org` | Recent-content list created during a build | Generated |
| `sitemap.org` and `*-list.org` | Sitemap/list sources created by `org-publish` | Generated |
| `output/` | Complete publish output | Generated |
| `.org-timestamps/` | Incremental publish cache | Generated |
| `.packages/` | Repository-local Emacs package directory | Generated/cache |
| `build-logs/` | Gitea build monitoring script, cache, and logs | Mixed; script is source, logs/cache are runtime data |
| `docs/` | Historical/design notes for individual features | Supplemental; this README is the central engineering guide |
- blogs default to `blogs/YYYY/MM-month/slug.org` ## Build architecture
- posts default to `posts/slug.org`, or `posts/section/slug.org` when a section is provided
- new pages can use an explicit path under `blogs/` or `posts/`
- saving a page starts `emacs -Q --script build-site.el` and then regenerates the search index
Lima pages are Markdown files under `lima/`. Selecting the Lima page type shows a Markdown toolbar for common formatting and an attachment button. Uploaded images and videos are stored under `assets/images/hzone/YYYY/MM/` and inserted into the Markdown page. `build-site.el` is the entry point. It establishes absolute source/output roots, bootstraps `htmlize`, configures Org, loads the modules in `lisp/`, defines the publishing projects, and runs the build.
Saves enqueue builds. The editor remains usable while publishing runs, and the queue section shows the current build, pending builds, and recent build results. ```mermaid
flowchart TD
Entry["build-site.el"] --> Core["build-core.el: packages, Org, logging"]
Core --> Modules["Load build modules"]
Modules --> Projects["build-projects.el: org-publish projects"]
Projects --> Prepare["Validate output and timestamp cache"]
Prepare --> Inputs["Generate tag pages and comment manifest"]
Inputs --> Publish["org-publish-all"]
Publish --> OrgHTML["Org/Markdown to HTML"]
Publish --> AssetCopy["Copy assets"]
OrgHTML --> Sync["Final asset sync"]
AssetCopy --> Sync
Sync --> Summary["Build summary"]
```
### Build modules
| Module | Responsibility |
|---------------------|-------------------------------------------------------------------------------------------------------|
| `build-core.el` | Logging, timing, local package bootstrap, and base Org configuration |
| `build-assets.el` | Shared CSS/JS `<head>` manifest, cache-busted asset URLs, and asset synchronization |
| `build-html.el` | Custom HTML backend, page shell, Mermaid export, macros, body classes, tags, and comments |
| `build-projects.el` | `org-publish-project-alist` for the main site, posts, blogs, career, tags, Lima, categories, and assets |
| `build-runner.el` | Cache integrity, generated inputs, publishing, and build metrics |
| `build-lima.el` | Lima Org/Markdown conversion and neighboring attachment copying |
| `build-comments.el` | Comment opt-in detection, comment HTML injection, and comment-page manifest generation |
| `sitemaps.el` | Main Mermaid sitemap and specialized blog/post/career/category lists |
| `tags.el` | Tag discovery, generated tag pages, and tag markup in exported HTML |
| `recently-updated.el` | Recent page list generation using `#+DATE` with mtime fallback |
| `sidenotes.el` | Sidenote suppression rules for special page layouts |
The main `org-main` project recursively publishes every Org file, while narrower projects provide specialized sitemap behavior and options. That overlap is intentional: Org's timestamp cache prevents unnecessary repeated work in normal incremental builds.
### Generated inputs and outputs
Before publishing, the build writes:
- `tags/*.org` from `#+FILETAGS`/`#+TAGS` in posts and blogs.
- `assets/content/comment-pages.json` from pages with `#+COMMENTS: t`.
- `recently-updated.org` from the 26 most recent eligible Org pages.
- sitemap/list Org files through `org-publish`.
After publishing, `search-index-json.py` walks generated HTML, strips script/style markup with Beautiful Soup, and writes `output/search-index.json`. The browser builds a Lunr index from that JSON at page load.
```mermaid
flowchart LR
Metadata["Org metadata"] --> Tags["tags/*.org"]
Metadata --> Comments["comment-pages.json"]
Metadata --> Recent["recently-updated.org"]
Tags --> Publish["HTML publish"]
Comments --> Publish
Recent --> Publish
Publish --> Indexer["search-index-json.py"]
Indexer --> SearchJSON["output/search-index.json"]
SearchJSON --> Lunr["Browser Lunr index"]
```
## Prerequisites
For a full local build, install:
- GNU Emacs with Org mode (Org is bundled with normal Emacs distributions).
- Python 3 with `venv` support.
- `pandoc`, required when publishing Markdown files under `lima/`.
- Network access on the first build if `htmlize` is not already cached in `.packages/`.
Python packages are listed in `requirements.txt` and installed into `.venv` by Make. The default `make` workflow also invokes a machine-specific permission script with `sudo`; use the direct build commands below on machines that do not have that script.
## Build and development commands
### Portable local build
```sh
emacs -Q --script build-site.el
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python search-index-json.py
```
The generated site is in `output/`. Serve it over HTTP so fetch-based features work correctly, for example:
```sh
python3 -m http.server 8000 --directory output
```
Then open `http://127.0.0.1:8000`. Dynamic `/api/...` features require the external runtime services or an equivalent local proxy.
### Make targets
| Command | Effect |
|-------------------|-----------------------------------------------------------------------------------------|
| `make build` | Run the Emacs publishing pipeline |
| `make search` | Create/update `output/search-index.json`, creating `.venv` when needed |
| `make` | Set machine-specific permissions, remove `output/`, build, index search, and remove `.venv` |
| `make clean-output` | Delete the configured output directory |
| `make test-elisp` | Run the Emacs Lisp ERT suite |
| `make test` | Run tests in the sibling authoring-service repository |
| `make norm` | Move Emacs backup files into `backups/` |
| `make help` | List supported targets |
### Build environment variables
| Variable | Meaning |
|-----------------------|--------------------------------------------------------------------------|
| `SITE_OUTPUT_DIR=/path` | Publish somewhere other than `output/`; the Python indexer respects it too |
| `SITE_FORCE=1` | Republish every file instead of using Org timestamps |
| `SITE_DRY=1` | Generate/prepare inputs but skip `org-publish-all` |
| `SITE_CLEAN=1` | Delete output and `.org-timestamps/`, then force a full publish |
The build checks that assets and an entry page exist in the output. If the output is missing or incomplete while timestamps remain, it clears the timestamp cache and forces a full publish to avoid a falsely successful incremental build.
## Authoring content
### Org pages
A typical page is:
```org
#+TITLE: Page title
#+DATE: <2026-07-12 Sun 12:00>
#+FILETAGS: :topic:notes:
#+OPTIONS: num:nil toc:t
#+COMMENTS: t
#+SLUG: stable-page-slug
* First heading
Content goes here.
```
The important metadata is:
- `#+TITLE`: displayed title and sitemap/search label.
- `#+DATE`: canonical publication time and ordering source. Recently updated falls back to filesystem modification time when absent or invalid.
- `#+FILETAGS`: colon-delimited tags used for category/tag pages and rendered under the page title.
- `#+OPTIONS`: normal Org export controls such as `toc:nil`, `num:nil`, or `title:nil`.
- `#+COMMENTS: t`: injects the comment UI and adds the page to the comment manifest.
- `#+SLUG`: stable comment API identifier; defaults to the filename when omitted.
- `#+NO_SIDENOTES`: any non-empty value adds the `no-sidenotes` body class. The `NO_SIDENOTES` or `KANBAN` file tag and a heading containing `KANBAN` do the same.
Use root-relative URLs (`/assets/...`) for shared site assets. Relative Org file links are rewritten to their corresponding HTML links by the exporter.
### Supported export helpers
Mermaid diagrams can be written directly in Org:
```org
#+begin_src mermaid
flowchart LR
A[Write] --> B[Publish]
#+end_src
```
The custom HTML backend emits a Mermaid container, and the shared browser initializer renders it. The build also provides these Org macros:
- `{{{sidenote(id,text)}}}`
- `{{{epigraph(quote,attribution)}}}` and related epigraph variants
- `{{{kbd(text)}}}`
- `{{{margimg(url,alt,caption)}}}`
- `{{{countdown(datetime,label)}}}`
### Where new pages belong
- Blog entry: `blogs/YYYY/MM-month/slug.org`.
- General post: `posts/slug.org`.
- Career post: `posts/career/slug.org`.
- Utility/dashboard page: `home/slug.org`.
- Interactive experiment: `play/slug.org`, usually paired with `assets/scripts/pages/slug.js` and `assets/styles/pages/slug.css`.
- Lima page: `.org` or `.md` under `lima/`.
Do not manually maintain generated list, category, recent, tag, or output files; rebuild them from source.
### Lima Markdown publishing
Markdown under `lima/` is converted to temporary Org with Pandoc, given generated title/date/comment metadata, and then exported through the normal HTML backend. The first Markdown H1 becomes the page title. Sibling directories matching `.attachments.*` are copied beside the published page. Org files in `lima/` use the same attachment-copy behavior without Pandoc.
## Frontend architecture
All static browser assets live under `assets/` and are copied to `output/assets/`. `lisp/build-assets.el` is the authoritative list of globally loaded styles and scripts; it appends deterministic mtime/size query strings for cache busting.
### Styles
The modular CSS hierarchy is:
```text
assets/styles/
├── themes/ design tokens and themes
├── base/ element defaults
├── layout/ site shell and layout
├── components/ reusable UI components
├── pages/ page-specific presentation
├── features/ optional feature presentation
├── utilities/ responsive/utility rules
└── vendor/ third-party CSS
```
Top-level CSS files are compatibility manifests that import the modular files. Prefer editing the modular source, while preserving the top-level entry points until old handwritten references have been removed.
### Scripts
The intended JavaScript boundaries are:
```text
assets/scripts/
├── application/ cross-page use-case orchestration
├── ui/ DOM presentation behavior
├── pages/ page-specific applications
├── features/ optional site-wide features
└── vendor/ vendored third-party libraries
```
The shared script list is loaded on every page, so each module must safely exit when its expected DOM is absent. Page modules should own page composition; reusable browser behavior belongs in `ui/` or `features/`. Top-level duplicate filenames are legacy loaders or compatibility copies—edit the modular file referenced by `build-assets.el` unless a page explicitly loads the legacy path.
Third-party browser dependencies are vendored: Lunr, Mermaid, BiggerPicture, and SVG Pan Zoom. Avoid editing minified vendor files directly.
## Dynamic features and service boundaries
The published HTML is static, but some browser modules rely on same-origin APIs:
| Feature | Browser module | Runtime endpoint |
|----------------------|----------------------------------|--------------------------------------------------|
| Comments and replies | `features/comments.js` | `GET /api/comments/:slug`, `POST /api/comments` |
| Notes board | `pages/notes.js` | `/api/notes` |
| Wird tracker | `pages/wird-tracker.js` | `/api/wird`, `/api/wird/motalah`, `/api/calibre/books` |
| Competency board | `pages/competency-status-board.js` | `/api/competencies/items...` |
| Resource Loader | `pages/resource-loader.js` | `/api/resource-loader`, `/api/resource-loader/thumbnails/:id` |
These APIs are not implemented here. Production routing must serve `output/` and proxy `/api/` to the appropriate backend. When changing an endpoint contract, coordinate the static client and its external service.
The hidden-details feature uses browser `localStorage`/`sessionStorage` for visit and discovery state and may call Open-Meteo after explicit geolocation interaction. Its friendly content source is `assets/content/hidden-details.json`; `assets/scripts/features/hidden-details.js` is generated by the external hidden-narrative authoring workflow. Treat both files as a pair and do not hand-edit only the generated JavaScript.
The Resource Loader at `/rl/` uses the same-origin `/api/resource-loader` backend as its sole metadata source, with ETags protecting concurrent edits. There is no browser metadata fallback. Generated cover thumbnails use `/api/resource-loader/thumbnails/:id` and are removed from local storage after upload. IndexedDB under `zxh-resource-loader` retains only imported PDF binaries on the device where they were added. JSON exports contain metadata and reading sessions, not PDF binaries. Its page code lives in `assets/scripts/pages/resource-loader.js`; the separately testable schema/progress/sync operations live in `resource-loader-model.js` and run with `make test-js`.
## Authoring service
The browser authoring UI is a separate sibling repository at:
```text
/home/zaine/master-folder/org-platform/authoring-service
```
It creates and edits the normal source files described above, stores Lima uploads under `assets/images/hzone/YYYY/MM/`, and enqueues site builds after saves. It is available locally at `http://127.0.0.1:8765` and, behind the production reverse proxy, at `https://author.zainezq.com`.
The Makefile delegates these commands to that repository:
- `make author` / `make author-start`
- `make author-install`
- `make author-stop`
- `make author-restart`
- `make author-health`
- `make author-status`
- `make author-diagnostics`
- `make author-logs`
They depend on the sibling repository existing at the hard-coded path and use its user systemd service.
## Testing and verification
Run the publishing tests:
```sh
make test-elisp
```
For a documentation/content-only change, a dry run validates package loading and generated inputs without rewriting all HTML:
```sh
SITE_DRY=1 emacs -Q --script build-site.el
```
For build or frontend changes, use a clean build, generate the search index, serve `output/`, and inspect the relevant pages in a browser:
```sh
SITE_CLEAN=1 emacs -Q --script build-site.el
.venv/bin/python search-index-json.py
python3 -m http.server 8000 --directory output
```
Verify at least:
- the home page and global navigation;
- a blog/post with a table of contents, tags, sidenotes, and comments as applicable;
- the relevant special page under `home/` or `play/`;
- search results and keyboard navigation;
- browser console/network failures;
- the generated sitemap and tag/category pages.
## Deployment and CI
`.gitea/workflows/build.yml` runs on pushes to `main`, nightly at midnight, and manual dispatch. Push events that only change the hidden-details JSON/generated JS pair are ignored to prevent a build loop.
```mermaid
sequenceDiagram
participant Git as Gitea main
participant Runner as org-web runner
participant Build as Site build
participant Monitor as PowerShell monitor
Git->>Runner: push, schedule, or manual dispatch
opt scheduled or manual
Runner->>Git: commit and push hidden-detail changes if present
end
Runner->>Build: SITE_CLEAN=1 make
Build-->>Runner: static output and logs
Runner->>Monitor: inspect result and authoring tests
Monitor-->>Runner: Discord notification
```
The runner is host-specific. It expects:
- the checkout at `/home/zaine/master-folder/org-platform/org_web`;
- output at that repository's `output/` directory;
- the permission helper referenced by the Makefile;
- PowerShell at `/snap/bin/pwsh`;
- the sibling authoring-service repository;
- Gitea/Discord credentials supplied as Actions secrets.
The workflow's monitor writes diagnostics and uses `DISCORD_WEBHOOK_URL` and `API_KEY`. Never commit those values or other service credentials.
## Safe change checklist
1. Edit source content, modular assets, or `lisp/`; do not edit `output/` as the source of a fix.
2. Check whether the file is generated or a compatibility wrapper before changing it.
3. Add new globally loaded assets to `lisp/build-assets.el`; keep page-only behavior scoped to its page when possible.
4. Preserve stable `#+SLUG` values on comment-enabled pages.
5. Run `make test-elisp` and the smallest representative build/browser checks for the change.
6. Review `git status` carefully: builds intentionally update generated Org files and manifests, while local logs and caches may also change.
7. Commit only the intended source and generated artifacts required by the workflow.
## Troubleshooting
### A build skips files that are missing from `output/`
Run with `SITE_CLEAN=1`. The normal integrity check catches a missing asset directory or entry page, but it does not validate every generated page.
### `htmlize` cannot be installed
The first build needs access to MELPA/ELPA unless `.packages/` already contains it. Restore network access or provide the cached package; the build cannot continue without syntax-highlighting support.
### Lima Markdown fails to publish
Confirm `pandoc` is installed and available on `PATH`. Plain Org publishing does not require Pandoc.
### Search is unavailable
Confirm `output/search-index.json` exists and the site is served over HTTP. Run `make search` after publishing HTML.
### Interactive data does not load locally
Static file serving does not provide `/api/...`. Run/proxy the relevant external services, or expect those features to show their empty/error state.
### The full `make` command fails outside the production host
`make` runs a hard-coded permission helper with `sudo`. Use `make build` followed by `make search`, or the portable commands above, on another machine.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

50
assets/content/comment-pages.json Normal file → Executable file
View File

@@ -379,11 +379,61 @@
"title": "Working on Eid day", "title": "Working on Eid day",
"url": "/blogs/2026/05-may/working-on-eid-day-27-05-26.html" "url": "/blogs/2026/05-may/working-on-eid-day-27-05-26.html"
}, },
{
"slug": "14-06-26-week-review",
"title": "[14-06-2026] - Weekly Review",
"url": "/blogs/2026/06-june/14-06-week-review.html"
},
{
"slug": "21-06-26-week-review",
"title": "[21-06-2026] - Weekly Review",
"url": "/blogs/2026/06-june/21-06-week-review.html"
},
{
"slug": "28-06-26-week-review",
"title": "[28-06-2026] - Weekly Review",
"url": "/blogs/2026/06-june/28-06-week-review.html"
},
{ {
"slug": "cleaning-up-the-repos", "slug": "cleaning-up-the-repos",
"title": "Cleaning up the repos", "title": "Cleaning up the repos",
"url": "/blogs/2026/06-june/cleaning-up-the-repos.html" "url": "/blogs/2026/06-june/cleaning-up-the-repos.html"
}, },
{
"slug": "05-07-26-week-review",
"title": "[05-07-2026] - Weekly Review",
"url": "/blogs/2026/07-july/05-07-week-review.html"
},
{
"slug": "12-07-26-week-review",
"title": "[12-07-2026] - Weekly Review",
"url": "/blogs/2026/07-july/12-07-week-review.html"
},
{
"slug": "19-07-26-week-review",
"title": "[19-07-2026] - Weekly Review",
"url": "/blogs/2026/07-july/19-07-week-review.html"
},
{
"slug": "26-07-26-week-review",
"title": "[26-07-2026] - Weekly Review",
"url": "/blogs/2026/07-july/26-07-week-review.html"
},
{
"slug": "02-08-26-week-review",
"title": "[02-08-2026] - Weekly Review",
"url": "/blogs/2026/08-august/02-08-week-review.html"
},
{
"slug": "09-08-26-week-review",
"title": "[09-08-2026] - Weekly Review",
"url": "/blogs/2026/08-august/09-08-week-review.html"
},
{
"slug": "16-08-26-week-review",
"title": "[16-08-2026] - Weekly Review",
"url": "/blogs/2026/08-august/16-08-week-review.html"
},
{ {
"slug": "publish-pages", "slug": "publish-pages",
"title": "How to publish pages using Org Publish", "title": "How to publish pages using Org Publish",

1785
assets/content/hidden-details.json Normal file → Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"vaults": [
{
"id": "vault-work",
"name": "Work",
"colour": "#526d82",
"folders": [],
"resources": [],
"createdAt": "2026-08-19T00:00:00.000Z",
"updatedAt": "2026-08-19T00:00:00.000Z"
},
{
"id": "vault-personal-study",
"name": "Personal Study",
"colour": "#9b6b43",
"folders": [],
"resources": [],
"createdAt": "2026-08-19T00:00:00.000Z",
"updatedAt": "2026-08-19T00:00:00.000Z"
}
]
}

Binary file not shown.

0
assets/icons/pen-icon.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

0
assets/images/blogs/2026-05-12-lima.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

View File

Before

Width:  |  Height:  |  Size: 860 KiB

After

Width:  |  Height:  |  Size: 860 KiB

0
assets/images/blogs/2026-05-13-200.gif Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 343 KiB

0
assets/images/blogs/2026-05-13-image.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 307 KiB

0
assets/images/blogs/2026-05-13-poppadoms.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

View File

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

0
assets/images/blogs/2026-05-13-spinning-spin.webp Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

0
assets/images/career/2026-06-01-xss-attack.webp Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -0,0 +1,32 @@
/* Function for setting the theme */
(function(){
const root = document.documentElement;
const storageKey = "theme";
const themes = ["light", "dark", "dark-academia"];
const labels = {
"light": "Light",
"dark": "Dark",
"dark-academia": "Academia"
};
const saved = localStorage.getItem(storageKey);
if (themes.includes(saved)) {
root.setAttribute("data-theme", saved);
}
const btn = document.getElementById("theme-toggle");
if (!btn) return;
const updateButton = () => {
const current = root.getAttribute("data-theme");
const label = labels[current] || "Auto";
btn.textContent = `Theme: ${label}`;
btn.setAttribute("aria-label", `Current theme: ${label}. Switch theme.`);
};
updateButton();
btn.addEventListener("click", () => {
const current = root.getAttribute("data-theme");
const index = themes.indexOf(current);
const target = themes[index === -1 ? 1 : (index + 1) % themes.length];
root.setAttribute("data-theme", target);
localStorage.setItem(storageKey, target);
updateButton();
});
})();

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,196 +1,7 @@
let pageSlug = null; (function () {
"use strict";
/* ----------------------------- var script = document.createElement("script");
* DOM builders script.src = "/assets/scripts/features/comments.js";
* ----------------------------- */ script.defer = true;
document.head.appendChild(script);
}());
function showReplyForm(container, parentId) {
if (container.querySelector(".reply-form")) return;
const form = document.createElement("form");
form.className = "reply-form";
form.innerHTML = `
<input type="text" name="author" placeholder="Your name (optional)">
<textarea name="content" required placeholder="Write a reply..."></textarea>
<button type="submit">Post reply</button>
`;
form.addEventListener("submit", async e => {
e.preventDefault();
const author = form.author.value.trim();
const content = form.content.value.trim();
if (!content) return;
const ok = await postComment(author, content, parentId);
if (ok) loadComments();
else alert("Failed to post reply");
});
container.appendChild(form);
}
function createComment(comment) {
const wrapper = document.createElement("div");
wrapper.className = "comment";
wrapper.dataset.id = comment.id;
wrapper.id = `comment-${comment.id}`;
const author = document.createElement("strong");
author.textContent = comment.author || "Anonymous";
const body = document.createElement("p");
body.textContent = comment.content;
const time = document.createElement("time");
time.className = "comment-date";
time.dateTime = comment.created_at;
time.textContent = new Date(comment.created_at).toLocaleString();
const replyBtn = document.createElement("button");
replyBtn.className = "comment-reply";
replyBtn.textContent = "Reply";
replyBtn.onclick = () =>
showReplyForm(wrapper, Number(comment.id));
wrapper.append(author, body, time, replyBtn);
if (comment.children?.length) {
const replies = document.createElement("div");
replies.className = "comment-children";
comment.children.forEach(child => {
replies.appendChild(createComment(child));
});
wrapper.appendChild(replies);
}
return wrapper;
}
function buildCommentTree(comments) {
const byId = {};
const roots = [];
comments.forEach(comment => {
comment.children = [];
byId[comment.id] = comment;
});
comments.forEach(comment => {
if (comment.parent_id) {
const parent = byId[comment.parent_id];
if (parent) parent.children.push(comment);
} else {
roots.push(comment);
}
});
return roots;
}
/* -----------------------------
* Rendering
* ----------------------------- */
function renderComments(comments) {
const list = document.getElementById("comments-list");
list.innerHTML = "";
if (!comments.length) {
const empty = document.createElement("p");
empty.className = "comments-empty";
empty.textContent = "No comments yet.";
list.appendChild(empty);
return;
}
const tree = buildCommentTree(comments);
tree.forEach(comment => list.appendChild(createComment(comment)));
}
/* -----------------------------
* API
* ----------------------------- */
async function fetchComments() {
const res = await fetch(`/api/comments/${pageSlug}`);
if (!res.ok) {
console.error("Failed to fetch comments");
return [];
}
return await res.json();
}
async function postComment(author, content, parentId = null) {
const res = await fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
page_slug: pageSlug,
author: author || null,
content,
parent_id: parentId
})
});
return res.ok;
}
/* -----------------------------
* Form handling
* ----------------------------- */
function wireCommentForm() {
const form = document.getElementById("comment-form");
form.addEventListener("submit", async event => {
event.preventDefault();
const authorInput = form.querySelector("input[name='author']");
const textarea = form.querySelector("textarea[name='content']");
const author = authorInput.value.trim();
const content = textarea.value.trim();
if (!content) return;
const ok = await postComment(author, content);
if (ok) {
textarea.value = "";
loadComments();
} else {
alert("Failed to post comment");
}
});
}
/* -----------------------------
* Entry point
* ----------------------------- */
async function loadComments() {
const comments = await fetchComments();
renderComments(comments);
}
function initComments() {
const section = document.getElementById("comments");
if (!section) return;
pageSlug = section.dataset.slug;
wireCommentForm();
loadComments();
}
initComments();

View File

@@ -1,240 +1,7 @@
const STATES = [ (function () {
{ key: "completed", label: "Completed" }, "use strict";
{ key: "manager_review", label: "Manager Review" }, var script = document.createElement("script");
{ key: "in_progress", label: "In Progress" }, script.src = "/assets/scripts/pages/competency-status-board.js";
{ key: "not_started", label: "Not Started" }, script.defer = true;
{ key: "comments", label: "Comments" }, document.head.appendChild(script);
]; }());
const LEVELS = [
{ key: "graduate", label: "Graduate" },
{ key: "engineer", label: "Engineer" },
];
let currentLevel = LEVELS[0].key;
function isMobile() {
return window.matchMedia("(max-width: 600px)").matches;
}
function renderLevelTabs() {
const board = document.getElementById("kanban-board");
if (!board || document.getElementById("level-tabs")) return;
const nav = document.createElement("div");
nav.id = "level-tabs";
LEVELS.forEach(({ key, label }) => {
const btn = document.createElement("button");
btn.textContent = label;
btn.dataset.level = key;
btn.className = "level-tab" + (key === currentLevel ? " active" : "");
btn.addEventListener("click", () => {
currentLevel = key;
document.querySelectorAll(".level-tab").forEach(b =>
b.classList.toggle("active", b.dataset.level === key)
);
loadBoard();
});
nav.appendChild(btn);
});
board.insertAdjacentElement("beforebegin", nav);
if (!document.getElementById("level-tab-styles")) {
const style = document.createElement("style");
style.id = "level-tab-styles";
style.textContent = `
#level-tabs {
display: flex;
gap: 0.5em;
margin-bottom: 1em;
}
.level-tab {
padding: 0.4em 1.2em;
border: 1px solid #ccc;
background: none;
cursor: pointer;
border-radius: 4px;
font-size: 0.95em;
}
.level-tab.active {
background: #333;
color: #fff;
border-color: #333;
}
`;
document.head.appendChild(style);
}
}
function populateMobileControls(items) {
if (!isMobile()) return;
const panel = document.getElementById("mobile-move-panel");
const itemSelect = document.getElementById("move-item");
const fromSelect = document.getElementById("move-from");
const toSelect = document.getElementById("move-to");
itemSelect.innerHTML = "<option value=''>Select competency</option>";
fromSelect.innerHTML = "<option value=''>From</option>";
toSelect.innerHTML = "<option value=''>To</option>";
items.forEach((item) => {
const opt = document.createElement("option");
opt.value = item.id;
opt.textContent = item.title;
opt.dataset.state = item.state;
itemSelect.appendChild(opt);
});
STATES.forEach((s) => {
fromSelect.appendChild(new Option(s.label, s.key));
toSelect.appendChild(new Option(s.label, s.key));
});
itemSelect.onchange = () => {
const selected = itemSelect.selectedOptions[0];
if (selected?.dataset.state) {
fromSelect.value = selected.dataset.state;
}
};
}
const moveConfirmBtn = document.getElementById("move-confirm");
if (moveConfirmBtn) {
moveConfirmBtn.addEventListener("click", async () => {
const itemId = document.getElementById("move-item").value;
const from = document.getElementById("move-from").value;
const to = document.getElementById("move-to").value;
if (!itemId || !to) {
alert("Select an item and target column");
return;
}
if (from === to) {
alert("Item is already in that column");
return;
}
const res = await fetch(`/api/competencies/items/${itemId}/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: to }),
});
if (res.ok) {
loadBoard();
} else {
alert("Failed to move competency");
}
});
}
let draggedItemId = null;
function countByState(items) {
return items.reduce((acc, item) => {
acc[item.state] = (acc[item.state] || 0) + 1;
return acc;
}, {});
}
async function updateProgress(items) {
const total = items.length;
const completed = items.filter(i => i.state === "completed").length;
const percent = total === 0 ? 0 : Math.round((completed / total) * 100);
const fill = document.getElementById("progress-fill");
const label = document.getElementById("progress-label");
if (fill) fill.style.width = `${percent}%`;
if (label) label.textContent = `${percent}% completed`;
}
function createCard(item) {
const card = document.createElement("div");
card.className = "kanban-card";
card.draggable = true;
card.dataset.id = item.id;
card.textContent = item.title;
card.addEventListener("dragstart", () => {
draggedItemId = item.id;
card.classList.add("dragging");
});
card.addEventListener("dragend", () => {
draggedItemId = null;
card.classList.remove("dragging");
});
return card;
}
function createColumn(state, items, counts) {
const col = document.createElement("div");
col.className = "kanban-column";
col.dataset.state = state.key;
const header = document.createElement("h3");
header.innerHTML = `
<span class="kanban-title">${state.label}</span>
<span class="kanban-count">${counts[state.key] || 0}</span>
`;
const list = document.createElement("div");
list.className = "kanban-list";
list.addEventListener("dragover", (e) => e.preventDefault());
list.addEventListener("drop", async () => {
if (!draggedItemId) return;
const res = await fetch(`/api/competencies/items/${draggedItemId}/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: state.key }),
});
if (res.ok) {
loadBoard();
} else {
alert("Failed to update state");
}
});
items
.filter((i) => i.state === state.key)
.forEach((i) => list.appendChild(createCard(i)));
col.appendChild(header);
col.appendChild(list);
return col;
}
function renderBoard(items) {
const board = document.getElementById("kanban-board");
if (!board) return;
board.innerHTML = "";
const counts = countByState(items);
STATES.forEach((state) => {
board.appendChild(createColumn(state, items, counts));
});
}
async function loadBoard() {
const res = await fetch(`/api/competencies/items?group=${currentLevel}`);
const items = await res.json();
updateProgress(items);
renderBoard(items);
populateMobileControls(items);
}
renderLevelTabs();
loadBoard();

View File

@@ -0,0 +1,196 @@
let pageSlug = null;
/* -----------------------------
* DOM builders
* ----------------------------- */
function showReplyForm(container, parentId) {
if (container.querySelector(".reply-form")) return;
const form = document.createElement("form");
form.className = "reply-form";
form.innerHTML = `
<input type="text" name="author" placeholder="Your name (optional)">
<textarea name="content" required placeholder="Write a reply..."></textarea>
<button type="submit">Post reply</button>
`;
form.addEventListener("submit", async e => {
e.preventDefault();
const author = form.author.value.trim();
const content = form.content.value.trim();
if (!content) return;
const ok = await postComment(author, content, parentId);
if (ok) loadComments();
else alert("Failed to post reply");
});
container.appendChild(form);
}
function createComment(comment) {
const wrapper = document.createElement("div");
wrapper.className = "comment";
wrapper.dataset.id = comment.id;
wrapper.id = `comment-${comment.id}`;
const author = document.createElement("strong");
author.textContent = comment.author || "Anonymous";
const body = document.createElement("p");
body.textContent = comment.content;
const time = document.createElement("time");
time.className = "comment-date";
time.dateTime = comment.created_at;
time.textContent = new Date(comment.created_at).toLocaleString();
const replyBtn = document.createElement("button");
replyBtn.className = "comment-reply";
replyBtn.textContent = "Reply";
replyBtn.onclick = () =>
showReplyForm(wrapper, Number(comment.id));
wrapper.append(author, body, time, replyBtn);
if (comment.children?.length) {
const replies = document.createElement("div");
replies.className = "comment-children";
comment.children.forEach(child => {
replies.appendChild(createComment(child));
});
wrapper.appendChild(replies);
}
return wrapper;
}
function buildCommentTree(comments) {
const byId = {};
const roots = [];
comments.forEach(comment => {
comment.children = [];
byId[comment.id] = comment;
});
comments.forEach(comment => {
if (comment.parent_id) {
const parent = byId[comment.parent_id];
if (parent) parent.children.push(comment);
} else {
roots.push(comment);
}
});
return roots;
}
/* -----------------------------
* Rendering
* ----------------------------- */
function renderComments(comments) {
const list = document.getElementById("comments-list");
list.innerHTML = "";
if (!comments.length) {
const empty = document.createElement("p");
empty.className = "comments-empty";
empty.textContent = "No comments yet.";
list.appendChild(empty);
return;
}
const tree = buildCommentTree(comments);
tree.forEach(comment => list.appendChild(createComment(comment)));
}
/* -----------------------------
* API
* ----------------------------- */
async function fetchComments() {
const res = await fetch(`/api/comments/${pageSlug}`);
if (!res.ok) {
console.error("Failed to fetch comments");
return [];
}
return await res.json();
}
async function postComment(author, content, parentId = null) {
const res = await fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
page_slug: pageSlug,
author: author || null,
content,
parent_id: parentId
})
});
return res.ok;
}
/* -----------------------------
* Form handling
* ----------------------------- */
function wireCommentForm() {
const form = document.getElementById("comment-form");
form.addEventListener("submit", async event => {
event.preventDefault();
const authorInput = form.querySelector("input[name='author']");
const textarea = form.querySelector("textarea[name='content']");
const author = authorInput.value.trim();
const content = textarea.value.trim();
if (!content) return;
const ok = await postComment(author, content);
if (ok) {
textarea.value = "";
loadComments();
} else {
alert("Failed to post comment");
}
});
}
/* -----------------------------
* Entry point
* ----------------------------- */
async function loadComments() {
const comments = await fetchComments();
renderComments(comments);
}
function initComments() {
const section = document.getElementById("comments");
if (!section) return;
pageSlug = section.dataset.slug;
wireCommentForm();
loadComments();
}
initComments();

View File

@@ -0,0 +1,655 @@
(function () {
"use strict";
/*
* Hidden site details
* -------------------
* This file adds small hidden interactions across the site: footer notes,
* click targets, search/keyboard secrets, rare messages, and page-specific
* behavior for the hidden /play pages.
*
* How to add your own things:
* - Add new text to EDITABLE CONTENT arrays such as `details`, `poems`,
* `greetings`, or `lore`.
* - Add search-triggered toast messages to `SEARCH_TOASTS`.
* - Add search-triggered page redirects to `SEARCH_ROUTES`.
* - Add typed keyboard phrases to `KEYBOARD_SECRETS` or `LONG_KEYBOARD_SECRETS`.
* - Add a new feature by writing an `addYourFeature(memory)` function and
* adding it to the `FEATURES` list at the bottom of the file.
*/
// Storage keys. v1 is read once so old visitors keep their hidden progress.
const STORAGE_KEY = "zxh_hidden_details_v2";
const OLD_STORAGE_KEY = "zxh_hidden_details_v1";
// Shared page context. These are captured once so daily random picks stay stable.
const now = new Date();
const hour = now.getHours();
const path = window.location.pathname;
const state = readState();
// -----------------------------
// EDITABLE CONTENT
// -----------------------------
// Generated by the hidden narrative authoring page.
// Friendly source of truth: assets/content/hidden-details.json
const familyLayers = [];
const details = [
"Keep one small appointment with yourself today.",
"Leave one kindness prepared for tomorrow-you.",
"Discipline can speak in a calm voice.",
"When the task feels heavy, remember the life it is trying to make possible.",
"Pause here: you are allowed to be proud of the person who kept returning."
];
const poems = [];
const greetings = [];
const nightMessages = [];
const lore = {
"quotes": [
"14/07/26 - big demo meeting with CEO tomorrow !!",
"Readiness often arrives ten minutes after you begin.",
"Rest is not a reward for becoming exhausted. It is maintenance for the person doing the becoming.",
"Before the world starts making requests, give your own life a few undivided minutes.",
"Every meaningful yes needs a few well-placed noes around it.",
"Motivation is weather. Build a shelter sturdy enough to keep going when the sky changes.",
"Do not design only for your best days. Create a minimum version of the habit that even a tired day can hold.",
"No repetition is wasted just because nobody applauded it.",
"Look backwards long enough to notice the ground you have already crossed.",
"Use the voice that helps you stand up again.",
"Sometimes courage is simply opening the document again.",
"When resistance feels enormous, promise only five minutes. The aim is not to finish; it is to make starting feel safe again.",
"If the habit keeps failing, redesign the doorway: put the book on the pillow, the shoes by the door, the difficult task at the hour your mind is clearest.",
"The exciting beginning and the visible result are connected by ordinary days. Learn to respect the bridge.",
"Completion creates its own kind of energy. Close one tab, send one message, clear one surface, finish one small loop."
],
"conversations": [],
"journals": [
"10/07/26 - surviving a heatwave whilst working :-)",
"You do not need the whole staircase today. Name the next honest step, make it small enough to begin, and let motion teach you what planning cannot.",
"Replace I am lazy with evidence: what drained me, what helped me, and what is the smallest change I can test tomorrow? Curiosity builds what shame only bruises.",
"A missed day is data, not destiny. Return before the story in your head turns one pause into an identity."
],
"warnings": [],
"dreams": [
"The path was hidden, but the compass still answered: values before mood, direction before speed, one careful footfall before certainty.",
"Long after inspiration went home, a single workshop light stayed on. Inside, patience was sanding rough effort into skill.",
"For a long time the seed had nothing visible to show. It was still becoming roots."
],
"cassettes": [],
"fakeUsers": [],
"seasonal": {},
"homepageTakeovers": [],
"roomLinks": []
};
// Exact search text -> toast message.
const SEARCH_TOASTS = {};
// Exact search text -> hidden page route.
const SEARCH_ROUTES = {};
// Short typed phrases. Stored in sessionStorage as a rolling key chain.
const KEYBOARD_SECRETS = [];
// Longer typed phrases and character names.
const LONG_KEYBOARD_SECRETS = [];
// -----------------------------
// STATE HELPERS
// -----------------------------
function readState() {
let current = {};
try {
current = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
} catch (_) {}
if (!current.visits) {
try {
const oldState = JSON.parse(localStorage.getItem(OLD_STORAGE_KEY) || "{}");
current = { ...oldState, migratedFromV1: true };
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
} catch (_) {}
}
return current;
}
function writeState(next) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch (_) {}
}
function pick(list, salt) {
const seed = hash(`${path}|${now.toDateString()}|${salt || ""}`);
return list[seed % list.length];
}
function hash(value) {
let h = 2166136261;
for (let i = 0; i < value.length; i += 1) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return Math.abs(h >>> 0);
}
// -----------------------------
// UI HELPERS
// -----------------------------
function hiddenMessageMarkup(message) {
return `<span class="hidden-message-text">${escapeHtml(message)}</span>`;
}
function setHiddenMessage(el, message) {
delete el.dataset.hiddenCharacter;
el.innerHTML = hiddenMessageMarkup(message);
}
function toast(message, duration) {
let el = document.querySelector(".hidden-toast");
if (!el) {
el = document.createElement("div");
el.className = "hidden-toast";
el.setAttribute("role", "status");
document.body.appendChild(el);
}
setHiddenMessage(el, message);
el.classList.add("is-visible");
window.clearTimeout(el._timer);
el._timer = window.setTimeout(() => el.classList.remove("is-visible"), duration || 5600);
}
function redirectTo(route) {
window.location.href = route;
}
function runSecretAction(secret) {
if (secret.route) redirectTo(secret.route);
if (secret.message) toast(secret.message);
if (secret.song) playTinySong();
}
function layerForSearch(value) {
if (value.includes("future")) return 4;
if (value.includes("sensei") || value.includes("aphy")) return 3;
return 2;
}
function rememberVisit() {
const visits = (state.visits || 0) + 1;
const seen = Array.isArray(state.seen) ? state.seen : [];
const pathCounts = { ...(state.pathCounts || {}) };
pathCounts[path] = (pathCounts[path] || 0) + 1;
const layerVisits = { ...(state.layerVisits || {}) };
const next = {
...state,
visits,
lastPath: path,
pathCounts,
layerVisits,
seen: Array.from(new Set([...seen, path])).slice(-100),
firstSeen: state.firstSeen || new Date().toISOString(),
lastSeen: new Date().toISOString()
};
writeState(next);
return next;
}
function awardLayer(layer, reason) {
const next = readState();
next.layerVisits = { ...(next.layerVisits || {}) };
next.layerVisits[layer] = (next.layerVisits[layer] || 0) + 1;
next.lastLayerReason = reason;
writeState(next);
}
// -----------------------------
// GLOBAL FEATURES
// -----------------------------
function addFooterNote(memory) {
const footer = document.querySelector("footer");
if (!footer || !details.length) return;
const note = document.createElement("p");
note.className = "hidden-footer-note";
const base = pick(details, "footer");
setHiddenMessage(note, memory.visits > 4 ? `${base} You have passed through ${memory.visits} times.` : base);
footer.appendChild(note);
}
function addHomepageGreeting(memory) {
const target = document.querySelector("#db-greeting");
if (!target || !greetings.length) return;
setHiddenMessage(target, pick(greetings, `greeting-${memory.visits}`));
const extra = document.createElement("p");
extra.className = "hidden-greeting";
setHiddenMessage(extra, "");
target.closest("div")?.appendChild(extra);
}
function addWhispers() {
// Event: click one of the tiny "?" marks appended to long paragraphs.
const paragraphs = Array.from(document.querySelectorAll("main p, #content p, article p")).filter((p) => p.textContent.trim().length > 80);
paragraphs.slice(0, 5).forEach((p, index) => {
if ((hash(path + index) + index) % 3 !== 0) return;
const mark = document.createElement("span");
mark.className = "hidden-whisper";
mark.tabIndex = 0;
mark.textContent = " ?";
const source = index % 2 ? poems : details;
if (!source.length) return;
mark.title = pick(source, `whisper-${index}`);
mark.addEventListener("click", () => {
awardLayer(index % 2 ? 2 : 1, "paragraph whisper");
toast(mark.title);
});
p.appendChild(mark);
});
}
function addCornerObject() {
// Event: click the small fixed button in the bottom-left corner.
const object = document.createElement("button");
object.className = "hidden-corner-object";
object.type = "button";
object.title = "hidden interaction";
object.textContent = state.visits % 2 ? "*" : "~";
document.body.appendChild(object);
let clicks = 0;
object.addEventListener("click", () => {
clicks += 1;
awardLayer(clicks > 3 ? 3 : 1, "corner object");
toast("Hidden interaction recorded.");
if (clicks === 7) window.location.href = "/play/left-behind.html";
});
}
function addLogoSearchAndKeyboardSecrets(memory) {
// Events:
// - Click `.site-brand` repeatedly on the homepage.
// - Type exact words into `#search-input`.
// - Type phrases anywhere on the page.
const nav = document.querySelector(".site-brand");
if (nav) {
let taps = Number(sessionStorage.getItem("zxh_logo_taps") || 0);
nav.addEventListener("click", (event) => {
if (path === "/" || path === "/index.html") {
event.preventDefault();
taps += 1;
sessionStorage.setItem("zxh_logo_taps", String(taps));
awardLayer(taps >= 5 ? 3 : 1, "logo taps");
toast(taps >= 5 ? "Hidden route available: /play/dream-corridor.html" : "Hidden interaction recorded.");
if (taps >= 8) window.location.href = "/play/dream-corridor.html";
}
});
}
const search = document.querySelector("#search-input");
if (search) {
search.addEventListener("input", () => {
const value = search.value.trim().toLowerCase();
if (SEARCH_TOASTS[value]) toast(SEARCH_TOASTS[value]);
if (SEARCH_ROUTES[value]) {
awardLayer(layerForSearch(value), `search ${value}`);
redirectTo(SEARCH_ROUTES[value]);
}
});
}
document.addEventListener("keydown", (event) => {
const key = event.key.toLowerCase();
const chain = `${sessionStorage.getItem("zxh_key_chain") || ""}${key}`.slice(-18);
sessionStorage.setItem("zxh_key_chain", chain);
KEYBOARD_SECRETS
.filter((secret) => chain.endsWith(secret.phrase))
.forEach(runSecretAction);
});
if (memory.seen?.length >= 6 && !state.travellerNoteShown) {
window.setTimeout(() => {
toast("Hidden path recorded.");
writeState({ ...readState(), travellerNoteShown: true });
}, 1600);
}
}
function addRareEvents() {
if (hour >= 22 || hour < 5) {
document.body.classList.add("hidden-late-night");
if (nightMessages.length) window.setTimeout(() => toast(pick(nightMessages, "night"), 2200), 900);
}
const roll = Math.random();
if (roll < 0.003) {
window.setTimeout(() => showDriftNote("", ""), 1800);
awardLayer(3, "rare sensei appearance");
} else if (roll < 0.008) {
window.setTimeout(() => toast("Hidden warning."), 2400);
awardLayer(4, "future warning");
}
}
function showDriftNote(text, art) {
const panel = document.createElement("aside");
panel.className = "hidden-drift-note";
panel.innerHTML = `<div class="hidden-drift-message">${hiddenMessageMarkup(text)}</div><pre>${escapeHtml(art)}</pre><button type="button">Close</button>`;
panel.querySelector("button").addEventListener("click", () => panel.remove());
document.body.appendChild(panel);
}
function addSecretLinks() {
[
["/play/left-behind.html", "left behind"],
["/play/dream-corridor.html", "dream corridor"],
["/play/kitchen-light.html", "kitchen light"],
...lore.roomLinks
].forEach(([href, label], index) => {
const link = document.createElement("a");
link.className = "hidden-secret-link";
link.href = href;
link.textContent = label;
link.style.left = `${index + 1}px`;
document.body.appendChild(link);
});
}
function enhanceErrors() {
if (document.title.match(/404|not found/i) || document.body.textContent.match(/404|not found/i)) {
toast("Page not found.");
}
}
function addContinuity(memory) {
const milestones = {
64: "Hidden layer unlocked."
};
if (milestones[memory.visits] && !state[`milestone_${memory.visits}`]) {
const layer = memory.visits >= 31 ? 4 : memory.visits >= 9 ? 3 : 2;
awardLayer(layer, `visit milestone ${memory.visits}`);
window.setTimeout(() => toast(milestones[memory.visits], 7000), 1200);
writeState({ ...readState(), [`milestone_${memory.visits}`]: true });
}
const count = memory.pathCounts?.[path] || 0;
if ([3, 7, 12].includes(count)) {
window.setTimeout(() => toast("Page revisit recorded.", 6400), 1700);
}
}
function addSeasonAndDates(memory) {
const month = now.getMonth();
const day = now.getDate();
const season = month < 2 || month === 11 ? "winter" : month < 5 ? "spring" : month < 8 ? "summer" : "autumn";
if (Math.random() < 0.18 && lore.seasonal[season]) window.setTimeout(() => toast(lore.seasonal[season], 5600), 2600);
const first = memory.firstSeen ? new Date(memory.firstSeen) : null;
if (first && memory.visits > 1 && first.getMonth() === month && first.getDate() === day) {
window.setTimeout(() => toast("Visitor anniversary recorded.", 7000), 1400);
}
}
function addObjectConstellation() {
// Event: click the small keepsake buttons along the bottom rail.
const rail = document.createElement("div");
rail.className = "hidden-object-rail";
rail.setAttribute("aria-label", "Hidden objects");
const objects = [
["ring", "Hidden object recorded."],
["bot", "Hidden object recorded."],
["tea", "Hidden object recorded."],
["crayon", "Hidden object recorded."],
["clock", "Hidden object recorded."]
];
objects.forEach(([name, message]) => {
const button = document.createElement("button");
button.type = "button";
button.className = `hidden-keepsake hidden-keepsake--${name}`;
button.title = name;
button.textContent = { ring: "o", bot: "#", tea: "u", crayon: "/", clock: ":" }[name];
button.addEventListener("click", () => {
const next = readState();
next.keepsakes = Array.from(new Set([...(next.keepsakes || []), name]));
writeState(next);
awardLayer(next.keepsakes.length >= 3 ? 3 : 2, `keepsake ${name}`);
toast(message);
if (next.keepsakes.length >= objects.length) {
window.setTimeout(() => toast("Hidden object set completed."), 1100);
}
});
rail.appendChild(button);
});
document.body.appendChild(rail);
}
function addWeatherWindow(memory) {
// Event: click the small "window" button. This asks for browser geolocation.
if (memory.visits < 3 || !("geolocation" in navigator)) return;
const button = document.createElement("button");
button.className = "hidden-weather-window";
button.type = "button";
button.title = "Check outside weather";
button.textContent = "window";
button.addEventListener("click", () => {
toast("Checking outside weather.");
navigator.geolocation.getCurrentPosition((pos) => {
const { latitude, longitude } = pos.coords;
fetch(`https://api.open-meteo.com/v1/forecast?latitude=${latitude.toFixed(3)}&longitude=${longitude.toFixed(3)}&current=temperature_2m,precipitation&timezone=auto`)
.then((res) => res.json())
.then((data) => {
const current = data.current || {};
const rain = Number(current.precipitation || 0) > 0;
const temp = Math.round(Number(current.temperature_2m));
toast(rain ? `Outside: rain, ${temp}C.` : `Outside: ${temp}C.`);
})
.catch(() => toast("Weather check failed."));
}, () => toast("No outside weather today."));
});
document.body.appendChild(button);
}
function addOldWebLayer(memory) {
if (Math.random() < 0.08 || memory.visits > 10) {
const stamp = document.createElement("div");
stamp.className = "hidden-oldweb-stamp";
if (!lore.fakeUsers.length) return;
stamp.title = pick(lore.fakeUsers, "fake-user");
stamp.textContent = "best viewed with patience";
stamp.addEventListener("click", () => {
awardLayer(1, "old web stamp");
toast(stamp.title);
});
document.body.appendChild(stamp);
}
const comments = document.querySelector("#comments");
if (comments && lore.fakeUsers.length && !comments.querySelector(".hidden-guestbook-line")) {
const line = document.createElement("p");
line.className = "hidden-guestbook-line";
setHiddenMessage(line, pick(lore.fakeUsers, "comment-lore"));
comments.appendChild(line);
}
}
function addSourceRelics() {
const marker = document.createComment("hidden-details");
document.documentElement.appendChild(marker);
document.querySelectorAll("img[alt=''], img:not([alt])").forEach((img, index) => {
if (index > 2) return;
img.alt = "Decorative image.";
});
}
function addLongKeyboardSecrets() {
// Event: type longer hidden phrases anywhere on the page.
document.addEventListener("keydown", (event) => {
const chain = `${sessionStorage.getItem("zxh_second_chain") || ""}${event.key.toLowerCase()}`.slice(-24);
sessionStorage.setItem("zxh_second_chain", chain);
LONG_KEYBOARD_SECRETS
.filter((secret) => chain.endsWith(secret.phrase))
.forEach(runSecretAction);
});
}
function addPatienceRewards(memory) {
// Event: no movement, key presses, scrolling, or clicking for 90 seconds.
if (memory.visits < 2) return;
let idleTimer = null;
const startIdle = () => {
window.clearTimeout(idleTimer);
idleTimer = window.setTimeout(() => {
awardLayer(5, "patience");
toast("Idle moment recorded.", 7600);
const next = readState();
next.patientMoments = (next.patientMoments || 0) + 1;
writeState(next);
}, 90000);
};
["mousemove", "keydown", "scroll", "click"].forEach((name) => document.addEventListener(name, startIdle, { passive: true }));
startIdle();
}
function addRareSecondLayer() {
const roll = Math.random();
if ((path === "/" || path === "/index.html") && roll < 0.006) {
document.body.classList.add("hidden-home-takeover");
if (lore.homepageTakeovers.length) window.setTimeout(() => showDriftNote(pick(lore.homepageTakeovers, "takeover"), ""), 600);
} else if (roll < 0.002) {
window.setTimeout(() => showDriftNote("", ""), 1500);
} else if (roll < 0.006) {
if (lore.dreams.length) window.setTimeout(() => toast(pick(lore.dreams, "rare-dream"), 7600), 2000);
} else if (roll < 0.014) {
if (lore.warnings.length) window.setTimeout(() => toast(pick(lore.warnings, "rare-warning"), 6800), 2300);
}
}
function addFamilyLayerIndex(memory) {
if (!path.includes("/play/family-layer-index")) return;
const target = document.querySelector("[data-family-layer-index]");
if (!target) return;
const visits = readState().layerVisits || {};
target.innerHTML = familyLayers.map((line, layer) => {
const count = visits[layer] || 0;
return `<li class="hidden-layer-message"><strong>${hiddenMessageMarkup(line)}</strong><br><span>local encounters: ${count}</span></li>`;
}).join("");
}
function addPageSpecificSecrets() {
// Events only used by specific `/play/...` pages.
if (path.includes("/play/cassette-log")) {
document.querySelectorAll("[data-cassette]").forEach((button, index) => {
button.addEventListener("click", () => {
awardLayer(index >= 2 ? 4 : 2, "voice note");
if (lore.cassettes.length) toast(lore.cassettes[index % lore.cassettes.length], 7600);
playTinySong();
});
});
}
if (path.includes("/play/patience-game")) {
const target = document.querySelector("[data-patience-target]");
const count = document.querySelector("[data-patience-count]");
if (target && count) {
let seconds = 0;
setInterval(() => {
seconds += 1;
count.textContent = String(seconds);
if ([30, 90, 180].includes(seconds)) {
setHiddenMessage(target, "Patience milestone recorded.");
}
}, 1000);
}
}
if (path.includes("/play/terminal-cupboard")) {
const input = document.querySelector("[data-cupboard-input]");
const log = document.querySelector("[data-cupboard-log]");
const commands = {
help: "commands: layer, exit",
layer: familyLayers.join("\n"),
exit: "Closed."
};
input?.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
const value = input.value.trim().toLowerCase();
const line = document.createElement("p");
setHiddenMessage(line, `> ${value}\n${commands[value] || "Unknown command."}`);
log.appendChild(line);
input.value = "";
});
}
}
function addInvisibleHoverSecrets() {
document.querySelectorAll("h1, h2").forEach((heading, index) => {
if (index > 5) return;
heading.classList.add("hidden-hover-memory");
const source = [...lore.quotes, ...lore.journals, ...poems];
if (!source.length) return;
heading.dataset.hiddenMemory = pick(source, `heading-${index}`);
});
}
function playTinySong() {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
const ctx = new AudioContext();
const notes = [392, 494, 440, 330, 392];
notes.forEach((freq, index) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = freq;
osc.type = "sine";
gain.gain.setValueAtTime(0.0001, ctx.currentTime + index * 0.18);
gain.gain.exponentialRampToValueAtTime(0.05, ctx.currentTime + index * 0.18 + 0.03);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + index * 0.18 + 0.16);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime + index * 0.18);
osc.stop(ctx.currentTime + index * 0.18 + 0.18);
});
} catch (_) {}
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
}[char]));
}
// Features run in this order on every page after the DOM is ready.
// Each function receives the current `memory` object.
const FEATURES = [
addFooterNote,
addHomepageGreeting,
addWhispers,
addCornerObject,
addLogoSearchAndKeyboardSecrets,
addRareEvents,
addSecretLinks,
enhanceErrors,
addContinuity,
addSeasonAndDates,
addObjectConstellation,
addWeatherWindow,
addOldWebLayer,
addSourceRelics,
addLongKeyboardSecrets,
addPatienceRewards,
addRareSecondLayer,
addFamilyLayerIndex,
addPageSpecificSecrets,
addInvisibleHoverSecrets
];
document.addEventListener("DOMContentLoaded", () => {
const memory = rememberVisit();
FEATURES.forEach((addFeature) => addFeature(memory));
});
}());

View File

@@ -1,315 +1,7 @@
document.addEventListener('DOMContentLoaded', () => { (function () {
if (typeof window.BiggerPicture !== 'function') { "use strict";
console.error('[gallery-init] BiggerPicture not found. Check script path.'); var script = document.createElement("script");
return; script.src = "/assets/scripts/ui/gallery-init.js";
} script.defer = true;
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'm4v', 'ogv']; document.head.appendChild(script);
}());
const isVideo = (href) => {
if (!href) return false;
href = href.toLowerCase();
return VIDEO_EXTENSIONS.some(ext => href.endsWith('.' + ext));
};
// 1b) Convert MP4 links into inline <video> players
// Convert video links into inline <video> players BUT keep the <a>
const videoLinks = document.querySelectorAll('a[href]');
videoLinks.forEach(a => {
const href = a.getAttribute("href");
if (!isVideo(href)) return;
const video = document.createElement("video");
video.controls = true;
video.preload = "metadata";
video.style.maxWidth = "100%";
video.style.borderRadius = "6px";
const source = document.createElement("source");
source.src = href;
source.type = "video/" + href.split('.').pop(); // guesses correct mime
video.appendChild(source);
// Replace the link with the inline video
a.parentNode.replaceChild(video, a);
});
// 1) Wrap Org-exported images so theyre clickable
const imgs = document.querySelectorAll('.figure img, img.org-svg');
imgs.forEach((img) => {
if (img.closest('a')) return; // already wrapped
const a = document.createElement('a');
const href = img.currentSrc || img.src;
a.href = href;
a.dataset.img = href; // lets BP pre-size/raster slides
a.dataset.alt = img.alt || '';
const setDims = () => {
a.dataset.width = img.naturalWidth || img.width || 1920;
a.dataset.height = img.naturalHeight || img.height || 1080;
};
if (img.complete) setDims(); else img.addEventListener('load', setDims);
img.style.cursor = 'zoom-in';
img.parentElement.insertBefore(a, img);
a.appendChild(img);
});
// 2) One global BP instance
const bp = BiggerPicture({ target: document.body });
// SVG pan/zoom handle
let activePanZoom = null;
const destroyPanZoom = () => { try { activePanZoom?.destroy(); } catch(_){} activePanZoom = null; };
// Simple rotate state (for non-SVG images)
let activeContainer = null;
let currentRotation = 0;
let rotateControls = null;
// 3) Build galleries per content container
const containers = document.querySelectorAll('main, article, .content, body');
containers.forEach((container) => {
const links = Array.from(container.querySelectorAll(
'.figure a, a:has(img.org-svg), a[href$=".mp4"], a[href$=".webm"], a[href$=".mov"], a[href$=".m4v"], a[href$=".ogv"]'
));
if (!links.length) return;
// Start the lightbox on click
links.forEach((link, index) => {
link.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll(".theme-toggle").forEach(el => {
el.classList.add("hidden");
});
bp.open({
// IMPORTANT: pass the anchor ELEMENTS, not custom objects
items: links,
el: link,
caption: (el) => el.querySelector('img')?.alt || el.title || '',
maxZoom: 40, // for raster images (PNG/JPG); SVG handled separately
// Fade-out polish + cleanup
onClose(containerEl) {
destroyPanZoom();
teardownRotation();
if (containerEl) containerEl.classList.add('bp-fadeout');
const themeToggle = document.querySelector(".theme-toggle");
if (themeToggle) {
themeToggle.classList.remove("hidden");
}
},
// Called once after open and on every slide change
//onOpen(containerEl) { setupRotation(containerEl); enhanceSVG(containerEl); }
onOpen(containerEl) {
const linkEl = bp.currItem;
const href = linkEl?.href || "";
if (href.endsWith(".mp4")) {
// Turn off image rotation (not applicable for video)
teardownRotation();
const htmlLayer = containerEl.querySelector(".bp-html");
const imgLayer = containerEl.querySelector(".bp-img");
if (!htmlLayer) return;
// Hide the default image layer
if (imgLayer) imgLayer.style.display = "none";
// Insert video player
htmlLayer.innerHTML = `
<video controls autoplay style="max-width:95vw; max-height:95vh">
<source src="${href}" type="video/mp4">
</video>
`;
return; // Do not run image/SVG enhancements
}
// Image behaviour
setupRotation(containerEl);
enhanceSVG(containerEl);
} ,
onUpdate(containerEl){ setupRotation(containerEl); enhanceSVG(containerEl); }
});
});
});
});
// 4) Simple rotate buttons for raster images
function ensureRotateControls() {
if (rotateControls) return rotateControls;
const wrapper = document.createElement('div');
wrapper.className = 'bp-rotate-controls';
Object.assign(wrapper.style, {
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
display: 'flex',
gap: '0.5rem',
zIndex: '9999',
pointerEvents: 'auto'
});
const mkBtn = (label, title) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = label;
btn.title = title;
btn.setAttribute('aria-label', title);
Object.assign(btn.style, {
padding: '0.4rem 0.6rem',
borderRadius: '999px',
border: 'none',
fontSize: '1.2rem',
cursor: 'pointer',
background: 'rgba(30,30,30,0.8)',
color: '#fff'
});
return btn;
};
const left = mkBtn('⟲', 'Rotate image 90° left');
const right = mkBtn('⟳', 'Rotate image 90° right');
left.addEventListener('click', (e) => {
e.stopPropagation(); // dont close the lightbox
if (!activeContainer) return;
currentRotation = (currentRotation - 90 + 360) % 360;
applyRotation();
});
right.addEventListener('click', (e) => {
e.stopPropagation();
if (!activeContainer) return;
currentRotation = (currentRotation + 90) % 360;
applyRotation();
});
wrapper.append(left, right);
document.body.appendChild(wrapper);
rotateControls = wrapper;
return rotateControls;
}
function ensureRotateWrapper() {
if (!activeContainer) return null;
const imgRoot = activeContainer.querySelector('.bp-img');
if (!imgRoot) return null;
let imgEl = imgRoot.querySelector('img');
if (!imgEl) return null;
const src = (imgEl.currentSrc || imgEl.src || '').toLowerCase();
// We only rotate raster images; SVGs are handled via svg-pan-zoom
if (src.endsWith('.svg')) return null;
let wrapper = imgRoot.querySelector('.bp-rotate-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'bp-rotate-wrapper';
wrapper.style.display = 'inline-block';
wrapper.style.transformOrigin = 'center center';
imgRoot.appendChild(wrapper);
wrapper.appendChild(imgEl);
} else if (!wrapper.contains(imgEl)) {
// Slide changed and BiggerPicture replaced the <img>
wrapper.innerHTML = '';
wrapper.appendChild(imgEl);
}
return wrapper;
}
function applyRotation() {
const wrapper = ensureRotateWrapper();
if (!wrapper) return;
wrapper.style.transform = `rotate(${currentRotation}deg)`;
}
function setupRotation(containerEl) {
activeContainer = containerEl;
currentRotation = 0;
const controls = ensureRotateControls();
controls.style.display = 'flex';
applyRotation();
}
function teardownRotation() {
activeContainer = null;
currentRotation = 0;
if (rotateControls) {
rotateControls.style.display = 'none';
}
}
// 4) If current slide is an SVG, swap to inline + enable svg-pan-zoom
async function enhanceSVG(containerEl) {
try {
destroyPanZoom();
const imgEl = containerEl.querySelector('.bp-img img');
if (!imgEl) return;
const src = imgEl.currentSrc || imgEl.src || '';
const isSVG = src.toLowerCase().endsWith('.svg');
const htmlLayer = containerEl.querySelector('.bp-html');
if (!isSVG || !htmlLayer) {
// ensure any previous holder is removed and bitmap is visible
const old = htmlLayer?.querySelector('.bp-svg-holder');
if (old) old.remove();
imgEl.style.visibility = '';
return;
}
// Create/clear holder
let holder = htmlLayer.querySelector('.bp-svg-holder');
if (!holder) {
holder = document.createElement('div');
holder.className = 'bp-svg-holder';
holder.style.maxWidth = '95vw';
holder.style.maxHeight = '95vh';
htmlLayer.appendChild(holder);
}
holder.innerHTML = '';
// Hide the bitmap so only the inline SVG shows
imgEl.style.visibility = 'hidden';
// Inline the SVG
const res = await fetch(src, { cache: 'force-cache' });
const text = await res.text();
holder.innerHTML = text;
const svg = holder.querySelector('svg');
if (!svg) { imgEl.style.visibility = ''; return; }
svg.style.maxWidth = '95vw';
svg.style.maxHeight = '95vh';
svg.style.display = 'block';
if (typeof window.svgPanZoom === 'function') {
activePanZoom = svgPanZoom(svg, {
zoomEnabled: true,
controlIconsEnabled: true,
fit: true,
center: true,
minZoom: 0.05,
maxZoom: 400, // effectively "unlimited"
zoomScaleSensitivity: 0.25,
dblClickZoomEnabled: true
});
// Keep wheel inside lightbox
holder.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true });
} else {
console.warn('[gallery-init] svg-pan-zoom not loaded');
}
} catch (err) {
console.error('[gallery-init] SVG enhance failed:', err);
}
}
});

692
assets/scripts/hidden-details.js Normal file → Executable file
View File

@@ -1,691 +1,7 @@
(function () { (function () {
"use strict"; "use strict";
var script = document.createElement("script");
/* script.src = "/assets/scripts/features/hidden-details.js";
* Hidden site details script.defer = true;
* ------------------- document.head.appendChild(script);
* This file adds small hidden interactions across the site: footer notes,
* click targets, search/keyboard secrets, rare messages, and page-specific
* behavior for the hidden /play pages.
*
* How to add your own things:
* - Add new text to EDITABLE CONTENT arrays such as `details`, `poems`,
* `greetings`, or `lore`.
* - Add search-triggered toast messages to `SEARCH_TOASTS`.
* - Add search-triggered page redirects to `SEARCH_ROUTES`.
* - Add typed keyboard phrases to `KEYBOARD_SECRETS` or `LONG_KEYBOARD_SECRETS`.
* - Add a new feature by writing an `addYourFeature(memory)` function and
* adding it to the `FEATURES` list at the bottom of the file.
*/
// Storage keys. v1 is read once so old visitors keep their hidden progress.
const STORAGE_KEY = "zxh_hidden_details_v2";
const OLD_STORAGE_KEY = "zxh_hidden_details_v1";
// Shared page context. These are captured once so daily random picks stay stable.
const now = new Date();
const hour = now.getHours();
const path = window.location.pathname;
const state = readState();
const CHARACTER_AVATARS = {
"lima": {
avatar: "/assets/avatars/lima.jpg",
fallback: "/assets/avatars/z.jpeg",
aliases: ["lima"],
glow: "#f2c58b"
},
"aphy": {
avatar: "/assets/avatars/aphy.jpeg",
fallback: "/assets/avatars/z.jpeg",
aliases: ["aphy"],
glow: "#9dd3a6"
},
"sensei chi": {
avatar: "/assets/avatars/sensei-chi.jpeg",
fallback: "/assets/avatars/z.jpeg",
aliases: ["sensei chi", "sensei"],
glow: "#9ccddd"
},
"young z": {
avatar: "/assets/avatars/young-z.jpeg",
fallback: "/assets/avatars/z.jpeg",
aliases: ["young z", "young"],
glow: "#f0b85c"
},
"future z": {
avatar: "/assets/avatars/future-z.jpeg",
fallback: "/assets/avatars/z.jpeg",
aliases: ["future z", "future"],
glow: "#c2a4ee"
},
"z": {
avatar: "/assets/avatars/z.jpeg",
fallback: "/assets/avatars/z.jpeg",
aliases: ["z", "zaine"],
glow: "#ead68e"
}
};
// -----------------------------
// EDITABLE CONTENT
// -----------------------------
// Generated by the hidden narrative authoring page.
// Friendly source of truth: assets/content/hidden-details.json
const familyLayers = [];
const details = [];
const poems = [];
const greetings = [];
const nightMessages = [];
const lore = {
"quotes": [],
"conversations": [],
"journals": [],
"warnings": [],
"dreams": [],
"cassettes": [],
"fakeUsers": [],
"seasonal": {},
"homepageTakeovers": [],
"roomLinks": []
};
// Exact search text -> toast message.
const SEARCH_TOASTS = {};
// Exact search text -> hidden page route.
const SEARCH_ROUTES = {};
// Short typed phrases. Stored in sessionStorage as a rolling key chain.
const KEYBOARD_SECRETS = [];
// Longer typed phrases and character names.
const LONG_KEYBOARD_SECRETS = [];
// -----------------------------
// STATE HELPERS
// -----------------------------
function readState() {
let current = {};
try {
current = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
} catch (_) {}
if (!current.visits) {
try {
const oldState = JSON.parse(localStorage.getItem(OLD_STORAGE_KEY) || "{}");
current = { ...oldState, migratedFromV1: true };
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
} catch (_) {}
}
return current;
}
function writeState(next) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch (_) {}
}
function pick(list, salt) {
const seed = hash(`${path}|${now.toDateString()}|${salt || ""}`);
return list[seed % list.length];
}
function hash(value) {
let h = 2166136261;
for (let i = 0; i < value.length; i += 1) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return Math.abs(h >>> 0);
}
// -----------------------------
// UI HELPERS
// -----------------------------
function characterForMessage(message) {
const text = String(message || "").toLowerCase();
return Object.entries(CHARACTER_AVATARS).find(([, config]) => {
return (config.aliases || []).some((alias) => {
const escaped = alias.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(^|[^a-z0-9-])${escaped}([^a-z0-9-]|$)`).test(text);
});
})?.[0] || "";
}
function avatarMarkup(character) {
const config = CHARACTER_AVATARS[character];
if (!config) return "";
return `<img class="hidden-message-avatar" src="${escapeHtml(config.avatar)}" alt="" loading="lazy" style="--hidden-avatar-glow:${escapeHtml(config.glow)}" onerror="this.onerror=null;this.src='${escapeHtml(config.fallback)}'">`;
}
function hiddenMessageMarkup(message) {
const character = characterForMessage(message);
return character
? `${avatarMarkup(character)}<span class="hidden-message-text">${escapeHtml(message)}</span>`
: `<span class="hidden-message-text">${escapeHtml(message)}</span>`;
}
function setHiddenMessage(el, message) {
const character = characterForMessage(message);
el.classList.toggle("has-hidden-avatar", Boolean(character));
if (character) {
el.dataset.hiddenCharacter = character;
el.innerHTML = hiddenMessageMarkup(message);
} else {
delete el.dataset.hiddenCharacter;
el.textContent = message;
}
}
function toast(message, duration) {
let el = document.querySelector(".hidden-toast");
if (!el) {
el = document.createElement("div");
el.className = "hidden-toast";
el.setAttribute("role", "status");
document.body.appendChild(el);
}
setHiddenMessage(el, message);
el.classList.add("is-visible");
window.clearTimeout(el._timer);
el._timer = window.setTimeout(() => el.classList.remove("is-visible"), duration || 5600);
}
function redirectTo(route) {
window.location.href = route;
}
function runSecretAction(secret) {
if (secret.route) redirectTo(secret.route);
if (secret.message) toast(secret.message);
if (secret.song) playTinySong();
}
function layerForSearch(value) {
if (value.includes("future")) return 4;
if (value.includes("sensei") || value.includes("aphy")) return 3;
return 2;
}
function rememberVisit() {
const visits = (state.visits || 0) + 1;
const seen = Array.isArray(state.seen) ? state.seen : [];
const pathCounts = { ...(state.pathCounts || {}) };
pathCounts[path] = (pathCounts[path] || 0) + 1;
const layerVisits = { ...(state.layerVisits || {}) };
const next = {
...state,
visits,
lastPath: path,
pathCounts,
layerVisits,
seen: Array.from(new Set([...seen, path])).slice(-100),
firstSeen: state.firstSeen || new Date().toISOString(),
lastSeen: new Date().toISOString()
};
writeState(next);
return next;
}
function awardLayer(layer, reason) {
const next = readState();
next.layerVisits = { ...(next.layerVisits || {}) };
next.layerVisits[layer] = (next.layerVisits[layer] || 0) + 1;
next.lastLayerReason = reason;
writeState(next);
}
// -----------------------------
// GLOBAL FEATURES
// -----------------------------
function addFooterNote(memory) {
const footer = document.querySelector("footer");
if (!footer || !details.length) return;
const note = document.createElement("p");
note.className = "hidden-footer-note";
const base = pick(details, "footer");
setHiddenMessage(note, memory.visits > 4 ? `${base} You have passed through ${memory.visits} times.` : base);
footer.appendChild(note);
}
function addHomepageGreeting(memory) {
const target = document.querySelector("#db-greeting");
if (!target || !greetings.length) return;
setHiddenMessage(target, pick(greetings, `greeting-${memory.visits}`));
const extra = document.createElement("p");
extra.className = "hidden-greeting";
setHiddenMessage(extra, "");
target.closest("div")?.appendChild(extra);
}
function addWhispers() {
// Event: click one of the tiny "?" marks appended to long paragraphs.
const paragraphs = Array.from(document.querySelectorAll("main p, #content p, article p")).filter((p) => p.textContent.trim().length > 80);
paragraphs.slice(0, 5).forEach((p, index) => {
if ((hash(path + index) + index) % 3 !== 0) return;
const mark = document.createElement("span");
mark.className = "hidden-whisper";
mark.tabIndex = 0;
mark.textContent = " ?";
const source = index % 2 ? poems : details;
if (!source.length) return;
mark.title = pick(source, `whisper-${index}`);
mark.addEventListener("click", () => {
awardLayer(index % 2 ? 2 : 1, "paragraph whisper");
toast(mark.title);
});
p.appendChild(mark);
});
}
function addCornerObject() {
// Event: click the small fixed button in the bottom-left corner.
const object = document.createElement("button");
object.className = "hidden-corner-object";
object.type = "button";
object.title = "hidden interaction";
object.textContent = state.visits % 2 ? "*" : "~";
document.body.appendChild(object);
let clicks = 0;
object.addEventListener("click", () => {
clicks += 1;
awardLayer(clicks > 3 ? 3 : 1, "corner object");
toast("Hidden interaction recorded.");
if (clicks === 7) window.location.href = "/play/left-behind.html";
});
}
function addLogoSearchAndKeyboardSecrets(memory) {
// Events:
// - Click `.site-brand` repeatedly on the homepage.
// - Type exact words into `#search-input`.
// - Type phrases anywhere on the page.
const nav = document.querySelector(".site-brand");
if (nav) {
let taps = Number(sessionStorage.getItem("zxh_logo_taps") || 0);
nav.addEventListener("click", (event) => {
if (path === "/" || path === "/index.html") {
event.preventDefault();
taps += 1;
sessionStorage.setItem("zxh_logo_taps", String(taps));
awardLayer(taps >= 5 ? 3 : 1, "logo taps");
toast(taps >= 5 ? "Hidden route available: /play/dream-corridor.html" : "Hidden interaction recorded.");
if (taps >= 8) window.location.href = "/play/dream-corridor.html";
}
});
}
const search = document.querySelector("#search-input");
if (search) {
search.addEventListener("input", () => {
const value = search.value.trim().toLowerCase();
if (SEARCH_TOASTS[value]) toast(SEARCH_TOASTS[value]);
if (SEARCH_ROUTES[value]) {
awardLayer(layerForSearch(value), `search ${value}`);
redirectTo(SEARCH_ROUTES[value]);
}
});
}
document.addEventListener("keydown", (event) => {
const key = event.key.toLowerCase();
const chain = `${sessionStorage.getItem("zxh_key_chain") || ""}${key}`.slice(-18);
sessionStorage.setItem("zxh_key_chain", chain);
KEYBOARD_SECRETS
.filter((secret) => chain.endsWith(secret.phrase))
.forEach(runSecretAction);
});
if (memory.seen?.length >= 6 && !state.travellerNoteShown) {
window.setTimeout(() => {
toast("Hidden path recorded.");
writeState({ ...readState(), travellerNoteShown: true });
}, 1600);
}
}
function addRareEvents() {
if (hour >= 22 || hour < 5) {
document.body.classList.add("hidden-late-night");
if (nightMessages.length) window.setTimeout(() => toast(pick(nightMessages, "night"), 2200), 900);
}
const roll = Math.random();
if (roll < 0.003) {
window.setTimeout(() => showDriftNote("", ""), 1800);
awardLayer(3, "rare sensei appearance");
} else if (roll < 0.008) {
window.setTimeout(() => toast("Hidden warning."), 2400);
awardLayer(4, "future warning");
}
}
function showDriftNote(text, art) {
const panel = document.createElement("aside");
panel.className = "hidden-drift-note";
const character = characterForMessage(text);
panel.innerHTML = `<div class="hidden-drift-message${character ? " has-hidden-avatar" : ""}">${hiddenMessageMarkup(text)}</div><pre>${escapeHtml(art)}</pre><button type="button">Close</button>`;
panel.querySelector("button").addEventListener("click", () => panel.remove());
document.body.appendChild(panel);
}
function addSecretLinks() {
[
["/play/left-behind.html", "left behind"],
["/play/dream-corridor.html", "dream corridor"],
["/play/kitchen-light.html", "kitchen light"],
...lore.roomLinks
].forEach(([href, label], index) => {
const link = document.createElement("a");
link.className = "hidden-secret-link";
link.href = href;
link.textContent = label;
link.style.left = `${index + 1}px`;
document.body.appendChild(link);
});
}
function enhanceErrors() {
if (document.title.match(/404|not found/i) || document.body.textContent.match(/404|not found/i)) {
toast("Page not found.");
}
}
function addContinuity(memory) {
const milestones = {
64: "Hidden layer unlocked."
};
if (milestones[memory.visits] && !state[`milestone_${memory.visits}`]) {
const layer = memory.visits >= 31 ? 4 : memory.visits >= 9 ? 3 : 2;
awardLayer(layer, `visit milestone ${memory.visits}`);
window.setTimeout(() => toast(milestones[memory.visits], 7000), 1200);
writeState({ ...readState(), [`milestone_${memory.visits}`]: true });
}
const count = memory.pathCounts?.[path] || 0;
if ([3, 7, 12].includes(count)) {
window.setTimeout(() => toast("Page revisit recorded.", 6400), 1700);
}
}
function addSeasonAndDates(memory) {
const month = now.getMonth();
const day = now.getDate();
const season = month < 2 || month === 11 ? "winter" : month < 5 ? "spring" : month < 8 ? "summer" : "autumn";
if (Math.random() < 0.18 && lore.seasonal[season]) window.setTimeout(() => toast(lore.seasonal[season], 5600), 2600);
const first = memory.firstSeen ? new Date(memory.firstSeen) : null;
if (first && memory.visits > 1 && first.getMonth() === month && first.getDate() === day) {
window.setTimeout(() => toast("Visitor anniversary recorded.", 7000), 1400);
}
}
function addObjectConstellation() {
// Event: click the small keepsake buttons along the bottom rail.
const rail = document.createElement("div");
rail.className = "hidden-object-rail";
rail.setAttribute("aria-label", "Hidden objects");
const objects = [
["ring", "Hidden object recorded."],
["bot", "Hidden object recorded."],
["tea", "Hidden object recorded."],
["crayon", "Hidden object recorded."],
["clock", "Hidden object recorded."]
];
objects.forEach(([name, message]) => {
const button = document.createElement("button");
button.type = "button";
button.className = `hidden-keepsake hidden-keepsake--${name}`;
button.title = name;
button.textContent = { ring: "o", bot: "#", tea: "u", crayon: "/", clock: ":" }[name];
button.addEventListener("click", () => {
const next = readState();
next.keepsakes = Array.from(new Set([...(next.keepsakes || []), name]));
writeState(next);
awardLayer(next.keepsakes.length >= 3 ? 3 : 2, `keepsake ${name}`);
toast(message);
if (next.keepsakes.length >= objects.length) {
window.setTimeout(() => toast("Hidden object set completed."), 1100);
}
});
rail.appendChild(button);
});
document.body.appendChild(rail);
}
function addWeatherWindow(memory) {
// Event: click the small "window" button. This asks for browser geolocation.
if (memory.visits < 3 || !("geolocation" in navigator)) return;
const button = document.createElement("button");
button.className = "hidden-weather-window";
button.type = "button";
button.title = "Check outside weather";
button.textContent = "window";
button.addEventListener("click", () => {
toast("Checking outside weather.");
navigator.geolocation.getCurrentPosition((pos) => {
const { latitude, longitude } = pos.coords;
fetch(`https://api.open-meteo.com/v1/forecast?latitude=${latitude.toFixed(3)}&longitude=${longitude.toFixed(3)}&current=temperature_2m,precipitation&timezone=auto`)
.then((res) => res.json())
.then((data) => {
const current = data.current || {};
const rain = Number(current.precipitation || 0) > 0;
const temp = Math.round(Number(current.temperature_2m));
toast(rain ? `Outside: rain, ${temp}C.` : `Outside: ${temp}C.`);
})
.catch(() => toast("Weather check failed."));
}, () => toast("No outside weather today."));
});
document.body.appendChild(button);
}
function addOldWebLayer(memory) {
if (Math.random() < 0.08 || memory.visits > 10) {
const stamp = document.createElement("div");
stamp.className = "hidden-oldweb-stamp";
if (!lore.fakeUsers.length) return;
stamp.title = pick(lore.fakeUsers, "fake-user");
stamp.textContent = "best viewed with patience";
stamp.addEventListener("click", () => {
awardLayer(1, "old web stamp");
toast(stamp.title);
});
document.body.appendChild(stamp);
}
const comments = document.querySelector("#comments");
if (comments && lore.fakeUsers.length && !comments.querySelector(".hidden-guestbook-line")) {
const line = document.createElement("p");
line.className = "hidden-guestbook-line";
setHiddenMessage(line, pick(lore.fakeUsers, "comment-lore"));
comments.appendChild(line);
}
}
function addSourceRelics() {
const marker = document.createComment("hidden-details");
document.documentElement.appendChild(marker);
document.querySelectorAll("img[alt=''], img:not([alt])").forEach((img, index) => {
if (index > 2) return;
img.alt = "Decorative image.";
});
}
function addLongKeyboardSecrets() {
// Event: type longer hidden phrases anywhere on the page.
document.addEventListener("keydown", (event) => {
const chain = `${sessionStorage.getItem("zxh_second_chain") || ""}${event.key.toLowerCase()}`.slice(-24);
sessionStorage.setItem("zxh_second_chain", chain);
LONG_KEYBOARD_SECRETS
.filter((secret) => chain.endsWith(secret.phrase))
.forEach(runSecretAction);
});
}
function addPatienceRewards(memory) {
// Event: no movement, key presses, scrolling, or clicking for 90 seconds.
if (memory.visits < 2) return;
let idleTimer = null;
const startIdle = () => {
window.clearTimeout(idleTimer);
idleTimer = window.setTimeout(() => {
awardLayer(5, "patience");
toast("Idle moment recorded.", 7600);
const next = readState();
next.patientMoments = (next.patientMoments || 0) + 1;
writeState(next);
}, 90000);
};
["mousemove", "keydown", "scroll", "click"].forEach((name) => document.addEventListener(name, startIdle, { passive: true }));
startIdle();
}
function addRareSecondLayer() {
const roll = Math.random();
if ((path === "/" || path === "/index.html") && roll < 0.006) {
document.body.classList.add("hidden-home-takeover");
if (lore.homepageTakeovers.length) window.setTimeout(() => showDriftNote(pick(lore.homepageTakeovers, "takeover"), ""), 600);
} else if (roll < 0.002) {
window.setTimeout(() => showDriftNote("", ""), 1500);
} else if (roll < 0.006) {
if (lore.dreams.length) window.setTimeout(() => toast(pick(lore.dreams, "rare-dream"), 7600), 2000);
} else if (roll < 0.014) {
if (lore.warnings.length) window.setTimeout(() => toast(pick(lore.warnings, "rare-warning"), 6800), 2300);
}
}
function addFamilyLayerIndex(memory) {
if (!path.includes("/play/family-layer-index")) return;
const target = document.querySelector("[data-family-layer-index]");
if (!target) return;
const visits = readState().layerVisits || {};
target.innerHTML = familyLayers.map((line, layer) => {
const count = visits[layer] || 0;
const character = characterForMessage(line);
return `<li class="${character ? "hidden-layer-message" : ""}"><strong>${hiddenMessageMarkup(line)}</strong><br><span>local encounters: ${count}</span></li>`;
}).join("");
}
function addPageSpecificSecrets() {
// Events only used by specific `/play/...` pages.
if (path.includes("/play/cassette-log")) {
document.querySelectorAll("[data-cassette]").forEach((button, index) => {
button.addEventListener("click", () => {
awardLayer(index >= 2 ? 4 : 2, "voice note");
if (lore.cassettes.length) toast(lore.cassettes[index % lore.cassettes.length], 7600);
playTinySong();
});
});
}
if (path.includes("/play/patience-game")) {
const target = document.querySelector("[data-patience-target]");
const count = document.querySelector("[data-patience-count]");
if (target && count) {
let seconds = 0;
setInterval(() => {
seconds += 1;
count.textContent = String(seconds);
if ([30, 90, 180].includes(seconds)) {
setHiddenMessage(target, "Patience milestone recorded.");
}
}, 1000);
}
}
if (path.includes("/play/terminal-cupboard")) {
const input = document.querySelector("[data-cupboard-input]");
const log = document.querySelector("[data-cupboard-log]");
const commands = {
help: "commands: layer, exit",
layer: familyLayers.join("\n"),
exit: "Closed."
};
input?.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
const value = input.value.trim().toLowerCase();
const line = document.createElement("p");
setHiddenMessage(line, `> ${value}\n${commands[value] || "Unknown command."}`);
log.appendChild(line);
input.value = "";
});
}
}
function addInvisibleHoverSecrets() {
document.querySelectorAll("h1, h2").forEach((heading, index) => {
if (index > 5) return;
heading.classList.add("hidden-hover-memory");
const source = [...lore.quotes, ...lore.journals, ...poems];
if (!source.length) return;
heading.dataset.hiddenMemory = pick(source, `heading-${index}`);
});
}
function playTinySong() {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
const ctx = new AudioContext();
const notes = [392, 494, 440, 330, 392];
notes.forEach((freq, index) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = freq;
osc.type = "sine";
gain.gain.setValueAtTime(0.0001, ctx.currentTime + index * 0.18);
gain.gain.exponentialRampToValueAtTime(0.05, ctx.currentTime + index * 0.18 + 0.03);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + index * 0.18 + 0.16);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime + index * 0.18);
osc.stop(ctx.currentTime + index * 0.18 + 0.18);
});
} catch (_) {}
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
}[char]));
}
// Features run in this order on every page after the DOM is ready.
// Each function receives the current `memory` object.
const FEATURES = [
addFooterNote,
addHomepageGreeting,
addWhispers,
addCornerObject,
addLogoSearchAndKeyboardSecrets,
addRareEvents,
addSecretLinks,
enhanceErrors,
addContinuity,
addSeasonAndDates,
addObjectConstellation,
addWeatherWindow,
addOldWebLayer,
addSourceRelics,
addLongKeyboardSecrets,
addPatienceRewards,
addRareSecondLayer,
addFamilyLayerIndex,
addPageSpecificSecrets,
addInvisibleHoverSecrets
];
document.addEventListener("DOMContentLoaded", () => {
const memory = rememberVisit();
FEATURES.forEach((addFeature) => addFeature(memory));
});
}()); }());

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,148 +1,7 @@
(function () { (function () {
const wall = document.getElementById("notes-wall"); "use strict";
const form = document.getElementById("notes-form"); var script = document.createElement("script");
const authorInput = document.getElementById("note-author"); script.src = "/assets/scripts/pages/notes.js";
const contentInput = document.getElementById("note-content"); script.defer = true;
const authorFilter = document.getElementById("notes-author-filter"); document.head.appendChild(script);
let notesCache = []; }());
if (!wall) return;
function escapeHtml(str) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function renderNote(note) {
const el = document.createElement("div");
el.className = "note";
el.innerHTML = `
<div class="note-meta">
<span class="note-author">${escapeHtml(note.author_name)}</span>
<time datetime="${note.created_at}">
${new Date(note.created_at).toLocaleString()}
</time>
</div>
<div class="note-content">
${escapeHtml(note.content).replace(/\n/g, "<br>")}
</div>
`;
return el;
}
function normaliseAuthor(author) {
return (author || "").trim();
}
function populateAuthorFilter(notes) {
if (!authorFilter) return;
const previousValue = authorFilter.value;
const authors = Array.from(
new Set(notes.map(note => normaliseAuthor(note.author_name)).filter(Boolean))
).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
authorFilter.innerHTML = '<option value="">All authors</option>';
authors.forEach(author => {
const option = document.createElement("option");
option.value = author;
option.textContent = author;
authorFilter.appendChild(option);
});
authorFilter.value = authors.includes(previousValue) ? previousValue : "";
}
function renderNotes() {
wall.innerHTML = "";
const selectedAuthor = authorFilter ? authorFilter.value : "";
const notes = selectedAuthor
? notesCache.filter(note => normaliseAuthor(note.author_name) === selectedAuthor)
: notesCache;
if (notesCache.length === 0) {
wall.innerHTML = "<p>No notes yet.</p>";
return;
}
if (notes.length === 0) {
wall.innerHTML = "<p>No notes for this author.</p>";
return;
}
notes.forEach(note => {
wall.appendChild(renderNote(note));
});
}
async function loadNotes() {
wall.innerHTML = "<p>Loading notes…</p>";
try {
const res = await fetch("/api/notes");
if (!res.ok) throw new Error("Failed to fetch notes");
const notes = await res.json();
notesCache = notes;
populateAuthorFilter(notesCache);
renderNotes();
} catch (err) {
console.error(err);
wall.innerHTML = "<p>Could not load notes.</p>";
}
}
async function submitNote(e) {
e.preventDefault();
const author = authorInput.value.trim();
const content = contentInput.value.trim();
if (!author || !content) return;
form.querySelector("button").disabled = true;
try {
const res = await fetch("/api/notes", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
author_name: author,
content: content,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || "Failed to post note");
}
contentInput.value = "";
await loadNotes();
} catch (err) {
alert(err.message);
} finally {
form.querySelector("button").disabled = false;
}
}
if (form) {
form.addEventListener("submit", submitNote);
}
if (authorFilter) {
authorFilter.addEventListener("change", renderNotes);
}
loadNotes();
})();

View File

@@ -0,0 +1,242 @@
const STATES = [
{ key: "completed", label: "Completed" },
{ key: "manager_review", label: "Manager Review" },
{ key: "in_progress", label: "In Progress" },
{ key: "not_started", label: "Not Started" },
{ key: "comments", label: "Comments" },
];
const LEVELS = [
{ key: "graduate", label: "Graduate" },
{ key: "engineer", label: "Engineer" },
];
let currentLevel = LEVELS[0].key;
function isMobile() {
return window.matchMedia("(max-width: 600px)").matches;
}
function renderLevelTabs() {
const board = document.getElementById("kanban-board");
if (!board || document.getElementById("level-tabs")) return;
const nav = document.createElement("div");
nav.id = "level-tabs";
LEVELS.forEach(({ key, label }) => {
const btn = document.createElement("button");
btn.textContent = label;
btn.dataset.level = key;
btn.className = "level-tab" + (key === currentLevel ? " active" : "");
btn.addEventListener("click", () => {
currentLevel = key;
document.querySelectorAll(".level-tab").forEach(b =>
b.classList.toggle("active", b.dataset.level === key)
);
loadBoard();
});
nav.appendChild(btn);
});
board.insertAdjacentElement("beforebegin", nav);
if (!document.getElementById("level-tab-styles")) {
const style = document.createElement("style");
style.id = "level-tab-styles";
style.textContent = `
#level-tabs {
display: flex;
gap: 0.5em;
margin-bottom: 1em;
}
.level-tab {
padding: 0.4em 1.2em;
border: 1px solid #ccc;
background: none;
cursor: pointer;
border-radius: 4px;
font-size: 0.95em;
}
.level-tab.active {
background: #333;
color: #fff;
border-color: #333;
}
`;
document.head.appendChild(style);
}
}
function populateMobileControls(items) {
if (!isMobile()) return;
const panel = document.getElementById("mobile-move-panel");
const itemSelect = document.getElementById("move-item");
const fromSelect = document.getElementById("move-from");
const toSelect = document.getElementById("move-to");
itemSelect.innerHTML = "<option value=''>Select competency</option>";
fromSelect.innerHTML = "<option value=''>From</option>";
toSelect.innerHTML = "<option value=''>To</option>";
items.forEach((item) => {
const opt = document.createElement("option");
opt.value = item.id;
opt.textContent = item.title;
opt.dataset.state = item.state;
itemSelect.appendChild(opt);
});
STATES.forEach((s) => {
fromSelect.appendChild(new Option(s.label, s.key));
toSelect.appendChild(new Option(s.label, s.key));
});
itemSelect.onchange = () => {
const selected = itemSelect.selectedOptions[0];
if (selected?.dataset.state) {
fromSelect.value = selected.dataset.state;
}
};
}
const moveConfirmBtn = document.getElementById("move-confirm");
if (moveConfirmBtn) {
moveConfirmBtn.addEventListener("click", async () => {
const itemId = document.getElementById("move-item").value;
const from = document.getElementById("move-from").value;
const to = document.getElementById("move-to").value;
if (!itemId || !to) {
alert("Select an item and target column");
return;
}
if (from === to) {
alert("Item is already in that column");
return;
}
const res = await fetch(`/api/competencies/items/${itemId}/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: to }),
});
if (res.ok) {
loadBoard();
} else {
alert("Failed to move competency");
}
});
}
let draggedItemId = null;
function countByState(items) {
return items.reduce((acc, item) => {
acc[item.state] = (acc[item.state] || 0) + 1;
return acc;
}, {});
}
async function updateProgress(items) {
const total = items.length;
const completed = items.filter(i => i.state === "completed").length;
const percent = total === 0 ? 0 : Math.round((completed / total) * 100);
const fill = document.getElementById("progress-fill");
const label = document.getElementById("progress-label");
if (fill) fill.style.width = `${percent}%`;
if (label) label.textContent = `${percent}% completed`;
}
function createCard(item) {
const card = document.createElement("div");
card.className = "kanban-card";
card.draggable = true;
card.dataset.id = item.id;
card.textContent = item.title;
card.addEventListener("dragstart", () => {
draggedItemId = item.id;
card.classList.add("dragging");
});
card.addEventListener("dragend", () => {
draggedItemId = null;
card.classList.remove("dragging");
});
return card;
}
function createColumn(state, items, counts) {
const col = document.createElement("div");
col.className = "kanban-column";
col.dataset.state = state.key;
const header = document.createElement("h3");
header.innerHTML = `
<span class="kanban-title">${state.label}</span>
<span class="kanban-count">${counts[state.key] || 0}</span>
`;
const list = document.createElement("div");
list.className = "kanban-list";
list.addEventListener("dragover", (e) => e.preventDefault());
list.addEventListener("drop", async () => {
if (!draggedItemId) return;
const res = await fetch(`/api/competencies/items/${draggedItemId}/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: state.key }),
});
if (res.ok) {
loadBoard();
} else {
alert("Failed to update state");
}
});
items
.filter((i) => i.state === state.key)
.forEach((i) => list.appendChild(createCard(i)));
col.appendChild(header);
col.appendChild(list);
return col;
}
function renderBoard(items) {
const board = document.getElementById("kanban-board");
if (!board) return;
board.innerHTML = "";
const counts = countByState(items);
STATES.forEach((state) => {
board.appendChild(createColumn(state, items, counts));
});
}
async function loadBoard() {
const res = await fetch(`/api/competencies/items?group=${currentLevel}`);
const items = await res.json();
updateProgress(items);
renderBoard(items);
populateMobileControls(items);
}
if (document.getElementById("kanban-board")) {
renderLevelTabs();
loadBoard();
}

View File

@@ -0,0 +1,313 @@
/* Home dashboard behaviour. All selectors are db-* scoped. */
(function () {
"use strict";
function updateGreeting() {
const el = document.getElementById("db-greeting");
if (!el) return;
const hour = new Date().getHours();
if (hour < 5) el.textContent = "Late session";
else if (hour < 12) el.textContent = "Good morning";
else if (hour < 17) el.textContent = "Good afternoon";
else if (hour < 21) el.textContent = "Good evening";
else el.textContent = "Evening review";
}
function updateClock() {
const el = document.getElementById("db-clock");
if (!el) return;
el.textContent = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date());
}
function getIsoWeek(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
}
function updateStats() {
const now = new Date();
const year = now.getFullYear();
const start = new Date(year, 0, 1);
const nextYear = new Date(year + 1, 0, 1);
const dayOfYear = Math.floor((now - start) / 86400000) + 1;
const daysInYear = Math.round((nextYear - start) / 86400000);
const daysLeft = Math.max(0, daysInYear - dayOfYear);
const month = new Intl.DateTimeFormat(undefined, { month: "short" }).format(now);
const pct = ((dayOfYear / daysInYear) * 100).toFixed(1);
setText("db-stat-day", dayOfYear);
setText("db-stat-week", "W" + getIsoWeek(now));
setText("db-stat-month", month);
setText("db-stat-left", daysLeft);
setText("db-year-pct", pct + "%");
const fill = document.getElementById("db-year-fill");
if (fill) fill.style.width = pct + "%";
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function escHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escAttr(str) {
return escHtml(str).replace(/'/g, "&#39;");
}
function normaliseHref(href, prefix) {
if (!href || href.startsWith("http") || href.startsWith("#")) return null;
if (href.startsWith("/")) return href;
const base = (prefix || "").replace(/\/$/, "");
if (!base || href.startsWith(base + "/")) return href;
return base + "/" + href;
}
function isTagLink(a) {
const href = a.getAttribute("href") || "";
return href.includes("/tags/") || Boolean(a.querySelector(".post-tag"));
}
function extractLinks(doc, max, prefix) {
const items = [];
const seen = new Set();
const links = doc.querySelectorAll("#content li a[href], .org-ul li a[href], ul li a[href]");
for (const a of links) {
if (isTagLink(a)) continue;
const href = normaliseHref(a.getAttribute("href"), prefix);
const title = a.textContent.trim();
if (!href || !title || seen.has(href)) continue;
const li = a.closest("li");
const dateMatch = li ? li.textContent.match(/\b\d{4}-\d{2}-\d{2}\b/) : null;
seen.add(href);
items.push({ href, title, date: dateMatch ? dateMatch[0] : null });
if (items.length >= max) break;
}
return items;
}
function renderFeed(ulId, items) {
const ul = document.getElementById(ulId);
if (!ul || !items.length) return;
ul.innerHTML = items
.map(({ href, title, date }) => (
`<li class="db-feed__item">` +
`<span class="db-feed__dot"></span>` +
`<a href="${escHtml(href)}">${escHtml(title)}</a>` +
(date ? `<span class="db-feed__meta">${escHtml(date)}</span>` : "") +
`</li>`
))
.join("");
}
async function loadFeed(listUrl, ulId, max, prefix) {
try {
const resp = await fetch(listUrl, { credentials: "same-origin" });
if (!resp.ok) return;
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, "text/html");
renderFeed(ulId, extractLinks(doc, max, prefix));
} catch (_) {
/* Fallback links remain in the HTML. */
}
}
function commentSlug(comment) {
return comment.pageSlug || comment.page_slug || "";
}
function commentDate(comment) {
return comment.created_at || comment.createdAt || "";
}
function commentHref(page, comment) {
if (!page?.url) return null;
if (!comment.id) return page.url + "#comments";
return page.url + "#comment-" + encodeURIComponent(comment.id);
}
function formatCommentDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function commentExcerpt(content) {
const text = String(content || "").replace(/\s+/g, " ").trim();
if (text.length <= 150) return text;
return text.slice(0, 147).trimEnd() + "...";
}
function renderRecentComments(comments, pageMap) {
const ul = document.getElementById("db-feed-comments");
if (!ul) return;
if (!comments.length) {
ul.innerHTML = (
`<li class="db-comment-feed__item">` +
`<span class="db-feed__dot"></span>` +
`<div class="db-comment-feed__body">` +
`<span class="db-comment-feed__empty">No comments yet.</span>` +
`</div>` +
`</li>`
);
return;
}
ul.innerHTML = comments.map((comment) => {
const slug = commentSlug(comment);
const page = pageMap.get(slug);
const title = page?.title || slug || "Unknown page";
const href = commentHref(page, comment);
const author = comment.author || "Anonymous";
const date = formatCommentDate(commentDate(comment));
const pageLink = href
? `<a class="db-comment-feed__page" href="${escAttr(href)}">${escHtml(title)}</a>`
: `<span class="db-comment-feed__page">${escHtml(title)}</span>`;
return (
`<li class="db-comment-feed__item">` +
`<span class="db-feed__dot"></span>` +
`<div class="db-comment-feed__body">` +
`<div class="db-comment-feed__top">` +
`<strong>${escHtml(author)}</strong>` +
`<span>on</span>` +
pageLink +
(date ? `<time datetime="${escAttr(commentDate(comment))}">${escHtml(date)}</time>` : "") +
`</div>` +
`<p>${escHtml(commentExcerpt(comment.content))}</p>` +
`</div>` +
`</li>`
);
}).join("");
}
async function loadCommentPageMap() {
try {
const resp = await fetch("/assets/content/comment-pages.json", { credentials: "same-origin" });
if (!resp.ok) return new Map();
const pages = await resp.json();
return new Map(
pages
.filter((page) => page.slug && page.url)
.map((page) => [page.slug, page])
);
} catch (_) {
return new Map();
}
}
async function loadRecentComments() {
const ul = document.getElementById("db-feed-comments");
if (!ul) return;
const renderUnavailable = () => {
ul.innerHTML = (
`<li class="db-comment-feed__item">` +
`<span class="db-feed__dot"></span>` +
`<div class="db-comment-feed__body">` +
`<span class="db-comment-feed__empty">Recent comments are unavailable.</span>` +
`</div>` +
`</li>`
);
};
try {
const [pageMap, resp] = await Promise.all([
loadCommentPageMap(),
fetch("/api/comments", { credentials: "same-origin" }),
]);
if (!resp.ok) {
renderUnavailable();
return;
}
const comments = await resp.json();
const recent = comments
.filter((comment) => commentDate(comment))
.sort((a, b) => new Date(commentDate(b)) - new Date(commentDate(a)))
.slice(0, 10);
renderRecentComments(recent, pageMap);
} catch (_) {
renderUnavailable();
}
}
function initCommandFilter() {
const input = document.getElementById("db-command-search");
const nav = document.getElementById("db-quicknav");
if (!input || !nav) return;
const items = Array.from(nav.querySelectorAll(".db-qn-item"));
const applyFilter = () => {
const query = input.value.trim().toLowerCase();
items.forEach((item) => {
const haystack = [
item.textContent,
item.getAttribute("href"),
item.dataset.keywords,
].join(" ").toLowerCase();
item.classList.toggle("is-hidden", Boolean(query && !haystack.includes(query)));
});
};
input.addEventListener("input", applyFilter);
document.addEventListener("keydown", (event) => {
if (event.key === "/" && !/^(input|textarea|select)$/i.test(event.target.tagName)) {
event.preventDefault();
input.focus();
}
});
}
function init() {
updateGreeting();
updateClock();
updateStats();
initCommandFilter();
setInterval(updateClock, 1000);
loadFeed("/blogs/blogs-list.html", "db-feed-blogs", 6, "/blogs");
loadFeed("/posts/posts-list.html", "db-feed-posts", 6, "/posts");
loadFeed("/recently-updated.html", "db-feed-recent", 6, "");
loadRecentComments();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

148
assets/scripts/pages/notes.js Executable file
View File

@@ -0,0 +1,148 @@
(function () {
const wall = document.getElementById("notes-wall");
const form = document.getElementById("notes-form");
const authorInput = document.getElementById("note-author");
const contentInput = document.getElementById("note-content");
const authorFilter = document.getElementById("notes-author-filter");
let notesCache = [];
if (!wall) return;
function escapeHtml(str) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function renderNote(note) {
const el = document.createElement("div");
el.className = "note";
el.innerHTML = `
<div class="note-meta">
<span class="note-author">${escapeHtml(note.author_name)}</span>
<time datetime="${note.created_at}">
${new Date(note.created_at).toLocaleString()}
</time>
</div>
<div class="note-content">
${escapeHtml(note.content).replace(/\n/g, "<br>")}
</div>
`;
return el;
}
function normaliseAuthor(author) {
return (author || "").trim();
}
function populateAuthorFilter(notes) {
if (!authorFilter) return;
const previousValue = authorFilter.value;
const authors = Array.from(
new Set(notes.map(note => normaliseAuthor(note.author_name)).filter(Boolean))
).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
authorFilter.innerHTML = '<option value="">All authors</option>';
authors.forEach(author => {
const option = document.createElement("option");
option.value = author;
option.textContent = author;
authorFilter.appendChild(option);
});
authorFilter.value = authors.includes(previousValue) ? previousValue : "";
}
function renderNotes() {
wall.innerHTML = "";
const selectedAuthor = authorFilter ? authorFilter.value : "";
const notes = selectedAuthor
? notesCache.filter(note => normaliseAuthor(note.author_name) === selectedAuthor)
: notesCache;
if (notesCache.length === 0) {
wall.innerHTML = "<p>No notes yet.</p>";
return;
}
if (notes.length === 0) {
wall.innerHTML = "<p>No notes for this author.</p>";
return;
}
notes.forEach(note => {
wall.appendChild(renderNote(note));
});
}
async function loadNotes() {
wall.innerHTML = "<p>Loading notes…</p>";
try {
const res = await fetch("/api/notes");
if (!res.ok) throw new Error("Failed to fetch notes");
const notes = await res.json();
notesCache = notes;
populateAuthorFilter(notesCache);
renderNotes();
} catch (err) {
console.error(err);
wall.innerHTML = "<p>Could not load notes.</p>";
}
}
async function submitNote(e) {
e.preventDefault();
const author = authorInput.value.trim();
const content = contentInput.value.trim();
if (!author || !content) return;
form.querySelector("button").disabled = true;
try {
const res = await fetch("/api/notes", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
author_name: author,
content: content,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || "Failed to post note");
}
contentInput.value = "";
await loadNotes();
} catch (err) {
alert(err.message);
} finally {
form.querySelector("button").disabled = false;
}
}
if (form) {
form.addEventListener("submit", submitNote);
}
if (authorFilter) {
authorFilter.addEventListener("change", renderNotes);
}
loadNotes();
})();

462
assets/scripts/pages/play.js Executable file
View File

@@ -0,0 +1,462 @@
(function () {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const shuffle = (items) => items.map((value) => ({ value, sort: Math.random() })).sort((a, b) => a.sort - b.sort).map((item) => item.value);
const quips = [
"A margin note blinks, then pretends it did not.",
"Somewhere, a bookmark changes its mind.",
"The archive is pleased by unnecessary curiosity.",
"A tiny bell rings in a room you have not built yet.",
"The page remembers that you came here."
];
document.addEventListener("DOMContentLoaded", () => {
const root = $(".play-root[data-play-page]");
if (!root) return;
const page = root.dataset.playPage;
const inits = { hub, memory, constellation, poem, bookshelf, ink, terminal, study, sigil };
if (inits[page]) inits[page](root);
});
function hub(root) {
const links = $$(".play-grid a", root);
const nodes = $$(".play-orbit__node", root);
const title = $("#play-hub-title", root);
const kind = $("#play-hub-kind", root);
const desc = $("#play-hub-desc", root);
const cta = $("#play-hub-link", root);
const whisper = document.createElement("p");
let active = 0;
whisper.className = "play-whisper";
whisper.setAttribute("aria-live", "polite");
$(".play-console__screen", root).appendChild(whisper);
nodes.forEach((node, index) => {
const angle = (index / nodes.length) * Math.PI * 2 - Math.PI / 2;
node.style.left = `${50 + Math.cos(angle) * 43}%`;
node.style.top = `${50 + Math.sin(angle) * 43}%`;
node.addEventListener("click", () => setActive(index));
});
function setActive(index) {
active = (index + links.length) % links.length;
const link = links[active];
links.forEach((item, i) => item.classList.toggle("is-active", i === active));
nodes.forEach((item, i) => item.classList.toggle("is-active", i === active));
title.textContent = link.querySelector("strong").textContent;
kind.textContent = link.dataset.kind;
desc.textContent = link.dataset.desc;
cta.href = link.href;
whisper.textContent = quips[active % quips.length];
}
links.forEach((link, index) => {
link.addEventListener("mouseenter", () => setActive(index));
link.addEventListener("focus", () => setActive(index));
});
$("[data-play-prev]", root).addEventListener("click", () => setActive(active - 1));
$("[data-play-next]", root).addEventListener("click", () => setActive(active + 1));
$("[data-play-random]", root).addEventListener("click", () => setActive(Math.floor(Math.random() * links.length)));
$(".play-orbit", root).addEventListener("dblclick", () => {
whisper.textContent = "You knocked twice. The cabinet knocked once back.";
root.classList.toggle("is-odd");
});
root.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft") setActive(active - 1);
if (event.key === "ArrowRight") setActive(active + 1);
});
setActive(0);
}
function memory(root) {
const symbols = ["Ink", "Lamp", "Tea", "Book", "Map", "Key"];
const board = $("[data-memory-board]", root);
const turnsEl = $("[data-memory-turns]", root);
const matchesEl = $("[data-memory-matches]", root);
const messageEl = $("[data-memory-message]", root);
let open = [];
let turns = 0;
let matches = 0;
let deck = [];
function render() {
board.innerHTML = "";
open = [];
turns = 0;
matches = 0;
deck = shuffle([...symbols, ...symbols]);
turnsEl.textContent = "0";
matchesEl.textContent = "0";
messageEl.textContent = "Cards are shuffled.";
deck.forEach((symbol, index) => {
const card = document.createElement("button");
card.type = "button";
card.className = "memory-card";
card.dataset.symbol = symbol;
card.textContent = "?";
card.style.setProperty("--tilt", `${(index % 5) - 2}deg`);
card.setAttribute("aria-label", "Hidden card");
card.addEventListener("click", () => flip(card));
board.appendChild(card);
});
}
function flip(card) {
if (card.classList.contains("is-open") || card.classList.contains("is-matched") || open.length === 2) return;
card.classList.add("is-open");
card.textContent = card.dataset.symbol;
card.setAttribute("aria-label", card.dataset.symbol);
open.push(card);
if (open.length !== 2) return;
turns += 1;
turnsEl.textContent = String(turns);
const [a, b] = open;
if (a.dataset.symbol === b.dataset.symbol) {
a.classList.add("is-matched");
b.classList.add("is-matched");
open = [];
matches += 1;
matchesEl.textContent = String(matches);
messageEl.textContent = matches === symbols.length ? `Cabinet solved in ${turns} turns.` : "A drawer clicks open.";
} else {
messageEl.textContent = "No match. The cabinet quietly re-files them.";
setTimeout(() => {
a.classList.remove("is-open");
b.classList.remove("is-open");
a.textContent = "?";
b.textContent = "?";
a.setAttribute("aria-label", "Hidden card");
b.setAttribute("aria-label", "Hidden card");
open = [];
}, 650);
}
}
$("[data-memory-reset]", root).addEventListener("click", render);
$("[data-memory-peek]", root).addEventListener("click", () => {
const hidden = $$(".memory-card:not(.is-open):not(.is-matched)", root);
hidden.forEach((card) => {
card.textContent = card.dataset.symbol;
card.classList.add("is-peeking");
});
messageEl.textContent = "A very brief lapse in scholarly discipline.";
setTimeout(() => {
hidden.forEach((card) => {
if (!card.classList.contains("is-open") && !card.classList.contains("is-matched")) card.textContent = "?";
card.classList.remove("is-peeking");
});
}, 900);
});
render();
}
function constellation(root) {
const canvas = $("[data-star-canvas]", root);
const ctx = canvas.getContext("2d");
const linesEl = $("[data-star-lines]", root);
const nameEl = document.createElement("strong");
const stars = Array.from({ length: 18 }, () => ({ x: 50 + Math.random() * 800, y: 45 + Math.random() * 430 }));
const lines = [];
let selected = null;
nameEl.className = "play-live-note";
linesEl.closest(".play-scorebar").appendChild(nameEl);
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#18130f";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "rgba(219, 184, 104, 0.65)";
ctx.lineWidth = 2;
lines.forEach(([a, b]) => {
ctx.beginPath();
ctx.moveTo(stars[a].x, stars[a].y);
ctx.lineTo(stars[b].x, stars[b].y);
ctx.stroke();
});
stars.forEach((star, index) => {
ctx.beginPath();
ctx.fillStyle = index === selected ? "#f6d889" : "#fff8e8";
ctx.arc(star.x, star.y, index === selected ? 7 : 4, 0, Math.PI * 2);
ctx.fill();
});
linesEl.textContent = String(lines.length);
if (lines.length >= 3) nameEl.textContent = `Named: ${pick(["The Patient Spoon", "The South Window", "The Tired Comet", "The Fifth Errand"])}`;
}
canvas.addEventListener("click", (event) => {
const rect = canvas.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * canvas.width;
const y = ((event.clientY - rect.top) / rect.height) * canvas.height;
const hit = stars.findIndex((star) => Math.hypot(star.x - x, star.y - y) < 22);
if (hit < 0) return;
if (selected === null) selected = hit;
else if (selected !== hit) {
lines.push([selected, hit]);
selected = hit;
}
draw();
});
$("[data-star-reset]", root).addEventListener("click", () => {
lines.length = 0;
selected = null;
nameEl.textContent = "";
draw();
});
draw();
}
function poem(root) {
const output = $("[data-poem-output]", root);
const a = ["In the margin", "By the kettle", "Under the desk lamp", "Between two errands", "After the house quiets"];
const b = ["a careful thought", "an old joke", "a stubborn question", "a useful mistake", "a bright scrap"];
const c = ["learns to wait.", "asks for a second reading.", "finds its proper shelf.", "becomes tomorrow's note.", "keeps the page warm."];
let count = 0;
function make() {
count += 1;
output.innerHTML = `${pick(a)}<br>${pick(b)}<br>${pick(c)}`;
output.dataset.stamp = count % 5 === 0 ? "approved by the margin" : "";
}
$("[data-poem-generate]", root).addEventListener("click", make);
make();
}
function bookshelf(root) {
const titles = ["Algebra at Breakfast", "Cabinet of Weather", "Domestic Orbits", "Evening Margins", "Household Engines", "Zettels and Tea"];
const shelf = $("[data-bookshelf]", root);
const status = $("[data-bookshelf-status]", root);
let sortedOnce = false;
setupSortable(shelf, render, check);
$("[data-bookshelf-shuffle]", root).addEventListener("click", () => render(shuffle(titles)));
render(shuffle(titles));
function render(items) {
shelf.innerHTML = "";
items.forEach((title) => {
const book = document.createElement("div");
book.className = "book-spine";
book.draggable = true;
book.dataset.value = title;
book.textContent = title;
shelf.appendChild(book);
});
check();
}
function check() {
const current = $$(".book-spine", shelf).map((book) => book.dataset.value);
const sorted = current.join("|") === titles.join("|");
status.textContent = sorted ? "Shelf sorted. A secret pamphlet slides out." : "Unsorted";
if (sorted && !sortedOnce) {
sortedOnce = true;
const pamphlet = document.createElement("div");
pamphlet.className = "booklet";
pamphlet.textContent = "Pamphlet: On the Correct Order of Small Things";
shelf.appendChild(pamphlet);
}
}
}
function ink(root) {
const canvas = $("[data-ink-canvas]", root);
const ctx = canvas.getContext("2d");
const drops = [];
let moon = false;
function addDrop(x, y, heavy) {
drops.push({ x, y, r: heavy ? 18 : 8, life: heavy ? 1.8 : 1, hue: 28 + Math.random() * 35 });
}
function pointer(event, heavy) {
const rect = canvas.getBoundingClientRect();
addDrop(((event.clientX - rect.left) / rect.width) * canvas.width, ((event.clientY - rect.top) / rect.height) * canvas.height, heavy);
}
canvas.addEventListener("pointermove", (event) => pointer(event, false));
canvas.addEventListener("click", (event) => pointer(event, true));
canvas.addEventListener("dblclick", () => {
moon = !moon;
});
function frame() {
ctx.fillStyle = "rgba(24, 19, 15, 0.16)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (moon) {
ctx.beginPath();
ctx.fillStyle = "rgba(246, 216, 137, 0.72)";
ctx.arc(760, 88, 34, 0, Math.PI * 2);
ctx.fill();
}
drops.forEach((drop) => {
drop.r += 0.7;
drop.life -= 0.012;
ctx.beginPath();
ctx.strokeStyle = `hsla(${drop.hue}, 65%, 68%, ${Math.max(drop.life, 0)})`;
ctx.lineWidth = 2;
ctx.arc(drop.x, drop.y, drop.r, 0, Math.PI * 2);
ctx.stroke();
});
for (let i = drops.length - 1; i >= 0; i -= 1) if (drops[i].life <= 0) drops.splice(i, 1);
requestAnimationFrame(frame);
}
frame();
}
function terminal(root) {
const log = $("[data-terminal-log]", root);
const form = $("[data-terminal-form]", root);
const input = $("[data-terminal-input]", root);
let kindness = 0;
const responses = {
help: "Commands: help, look, map, open drawer, read note, brew tea, hum, knock, save, inventory, clear",
look: "A narrow archive room. A lamp hums. A drawer is labelled MAYBE IMPORTANT.",
map: "You are between the reading desk, the family shelf, and the door back to Play.",
"open drawer": "Inside: a brass key, a receipt, and a note folded twice.",
"read note": "The note says: keep the site useful, but leave a few doors ajar.",
"brew tea": "The room smells briefly of cardamom. Nothing else changes, which is enough.",
hum: "You hum four careful notes. Something behind the wall hums five back.",
knock: "Knock. Knock. ... A polite pause. Knock.",
save: "You save your place in the archive. The archive saves its place in you.",
inventory: "You are carrying: a brass key, a warm cup, and one unreasonable hope."
};
function write(text) {
log.textContent += `${text}\n`;
log.scrollTop = log.scrollHeight;
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const cmd = input.value.trim().toLowerCase();
if (!cmd) return;
input.value = "";
if (cmd === "clear") {
log.textContent = "";
return;
}
write(`> ${cmd}`);
if (cmd === "pet book") {
kindness += 1;
write(kindness > 2 ? "The book follows you for exactly three pages." : "The book accepts this with suspicious dignity.");
} else {
write(responses[cmd] || "The archive declines to understand that command.");
}
});
write("Archive terminal ready. Type help. The cursor is listening.");
input.focus();
}
function study(root) {
const time = $("[data-study-time]", root);
const scene = $("[data-study-scene]", root);
let total = 300;
let left = total;
let timer = null;
const note = document.createElement("div");
note.className = "play-live-note";
note.textContent = "The lamp is cold.";
time.insertAdjacentElement("afterend", note);
function draw() {
const done = 1 - left / total;
scene.style.setProperty("--lamp", String(0.18 + done * 0.72));
scene.style.setProperty("--glow", `${done * 70}px`);
const m = String(Math.floor(left / 60)).padStart(2, "0");
const s = String(left % 60).padStart(2, "0");
time.textContent = `${m}:${s}`;
if (left === 0) note.textContent = "Focus complete. The desk looks proud.";
else if (done > 0.66) note.textContent = "The page has warmed through.";
else if (done > 0.33) note.textContent = "The lamp has settled into its work.";
}
function start() {
if (timer) return;
timer = setInterval(() => {
left = Math.max(0, left - 1);
draw();
if (left === 0) pause();
}, 1000);
}
function pause() {
clearInterval(timer);
timer = null;
}
$("[data-study-start]", root).addEventListener("click", start);
$("[data-study-pause]", root).addEventListener("click", pause);
$("[data-study-reset]", root).addEventListener("click", () => {
pause();
left = total;
draw();
});
draw();
}
function sigil(root) {
const canvas = $("[data-sigil-canvas]", root);
const ctx = canvas.getContext("2d");
const initials = $("[data-sigil-initials]", root);
const motto = $("[data-sigil-motto]", root);
let wax = "#9c6b2f";
const waxButton = document.createElement("button");
waxButton.type = "button";
waxButton.textContent = "New wax";
$("[data-sigil-form]", root).appendChild(waxButton);
function draw() {
const text = (initials.value || "ZXH").toUpperCase();
const phrase = motto.value || "Learn, make, remember";
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#fbf7ec";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.translate(260, 260);
ctx.strokeStyle = wax;
ctx.lineWidth = 10;
ctx.beginPath();
ctx.arc(0, 0, 190, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 2;
for (let i = 0; i < 12; i += 1) {
ctx.rotate(Math.PI / 6);
ctx.beginPath();
ctx.moveTo(0, -150);
ctx.lineTo(0, -185);
ctx.stroke();
}
ctx.rotate(-Math.PI * 2);
ctx.fillStyle = "#2a2118";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "900 94px Georgia, serif";
ctx.fillText(text.slice(0, 4), 0, -10);
ctx.font = "700 22px Georgia, serif";
ctx.fillText(phrase.slice(0, 34), 0, 88);
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
waxButton.addEventListener("click", () => {
wax = pick(["#9c6b2f", "#8a3f3f", "#486b57", "#3e5f8a", "#5f4b8b"]);
draw();
});
$("[data-sigil-form]", root).addEventListener("submit", (event) => {
event.preventDefault();
draw();
});
initials.addEventListener("input", draw);
motto.addEventListener("input", draw);
draw();
}
function setupSortable(container, _render, after) {
let dragged = null;
container.addEventListener("dragstart", (event) => {
dragged = event.target.closest("[draggable='true']");
if (dragged) event.dataTransfer.effectAllowed = "move";
});
container.addEventListener("dragover", (event) => {
event.preventDefault();
const target = event.target.closest("[draggable='true']");
if (!dragged || !target || target === dragged) return;
const rect = target.getBoundingClientRect();
const before = event.clientY < rect.top + rect.height / 2 || event.clientX < rect.left + rect.width / 2;
container.insertBefore(dragged, before ? target : target.nextSibling);
after();
});
container.addEventListener("dragend", () => {
dragged = null;
after();
});
}
function pick(items) {
return items[Math.floor(Math.random() * items.length)];
}
})();

View File

@@ -0,0 +1,261 @@
(function (root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
root.ResourceLoaderModel = api;
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
const SCHEMA_VERSION = 1;
const TYPES = ["book", "article", "video"];
const FORMATS = ["physical", "pdf", "web"];
const STATUSES = ["backlog", "shelf", "active", "completed", "archived"];
function id() {
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `rl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function now() {
return new Date().toISOString();
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function emptyVault(name, colour) {
const stamp = now();
return {
id: id(),
name,
colour: colour || "#9b6b43",
folders: [],
resources: [],
createdAt: stamp,
updatedAt: stamp,
};
}
function createSeed() {
return {
schemaVersion: SCHEMA_VERSION,
vaults: [emptyVault("Work", "#526d82"), emptyVault("Personal Study", "#9b6b43")],
};
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function validateLibrary(input) {
assert(input && typeof input === "object" && !Array.isArray(input), "The JSON root must be an object.");
assert(input.schemaVersion === SCHEMA_VERSION, `Unsupported schemaVersion; expected ${SCHEMA_VERSION}.`);
assert(Array.isArray(input.vaults), "vaults must be an array.");
const vaultIds = new Set();
input.vaults.forEach((vault, vaultIndex) => {
assert(vault && typeof vault === "object", `Vault ${vaultIndex + 1} is invalid.`);
assert(typeof vault.id === "string" && vault.id, `Vault ${vaultIndex + 1} needs an id.`);
assert(!vaultIds.has(vault.id), `Duplicate vault id: ${vault.id}`);
vaultIds.add(vault.id);
assert(typeof vault.name === "string" && vault.name.trim(), `Vault ${vaultIndex + 1} needs a name.`);
assert(Array.isArray(vault.folders), `folders must be an array in ${vault.name}.`);
assert(Array.isArray(vault.resources), `resources must be an array in ${vault.name}.`);
const folderIds = new Set();
vault.folders.forEach((folder) => {
assert(folder && typeof folder.id === "string" && folder.id, `A folder in ${vault.name} needs an id.`);
assert(!folderIds.has(folder.id), `Duplicate folder id: ${folder.id}`);
folderIds.add(folder.id);
assert(typeof folder.name === "string" && folder.name.trim(), `A folder in ${vault.name} needs a name.`);
});
const resourceIds = new Set();
vault.resources.forEach((resource) => {
assert(resource && typeof resource.id === "string" && resource.id, `A resource in ${vault.name} needs an id.`);
assert(!resourceIds.has(resource.id), `Duplicate resource id: ${resource.id}`);
resourceIds.add(resource.id);
assert(typeof resource.title === "string" && resource.title.trim(), "Every resource needs a title.");
assert(TYPES.includes(resource.type), `Invalid resource type: ${resource.type}`);
assert(FORMATS.includes(resource.format), `Invalid resource format: ${resource.format}`);
assert(STATUSES.includes(resource.status), `Invalid resource status: ${resource.status}`);
assert(!resource.folderId || folderIds.has(resource.folderId), `Resource ${resource.title} refers to a missing folder.`);
assert(!resource.sessions || Array.isArray(resource.sessions), `sessions must be an array for ${resource.title}.`);
});
});
return true;
}
function normaliseLibrary(input) {
validateLibrary(input);
const data = clone(input);
data.vaults.forEach((vault) => {
vault.colour = vault.colour || "#9b6b43";
vault.createdAt = vault.createdAt || now();
vault.updatedAt = vault.updatedAt || vault.createdAt;
vault.folders.forEach((folder) => {
folder.createdAt = folder.createdAt || now();
folder.updatedAt = folder.updatedAt || folder.createdAt;
});
vault.resources.forEach((resource) => {
resource.creators = Array.isArray(resource.creators) ? resource.creators : [];
resource.tags = Array.isArray(resource.tags) ? resource.tags : [];
resource.sessions = Array.isArray(resource.sessions) ? resource.sessions : [];
resource.folderId = resource.folderId || null;
resource.createdAt = resource.createdAt || now();
resource.updatedAt = resource.updatedAt || resource.createdAt;
});
});
return data;
}
function mergeById(existing, incoming) {
const map = new Map(existing.map((item) => [item.id, clone(item)]));
incoming.forEach((item) => map.set(item.id, clone(item)));
return Array.from(map.values());
}
function mergeLibraries(current, incoming) {
const base = normaliseLibrary(current);
const addition = normaliseLibrary(incoming);
const vaultMap = new Map(base.vaults.map((vault) => [vault.id, vault]));
addition.vaults.forEach((newVault) => {
const oldVault = vaultMap.get(newVault.id);
if (!oldVault) {
vaultMap.set(newVault.id, newVault);
return;
}
vaultMap.set(newVault.id, {
...oldVault,
...newVault,
folders: mergeById(oldVault.folders, newVault.folders),
resources: mergeById(oldVault.resources, newVault.resources),
});
});
const merged = { schemaVersion: SCHEMA_VERSION, vaults: Array.from(vaultMap.values()) };
return normaliseLibrary(merged);
}
function newestByTimestamp(remote, local) {
const remoteStamp = String(remote.updatedAt || remote.createdAt || "");
const localStamp = String(local.updatedAt || local.createdAt || "");
return clone(localStamp >= remoteStamp ? local : remote);
}
function mergeSyncItems(remoteItems, localItems, mergeMatch) {
const items = new Map(remoteItems.map((item) => [item.id, clone(item)]));
localItems.forEach((local) => {
const remote = items.get(local.id);
items.set(local.id, remote ? mergeMatch(remote, local) : clone(local));
});
return Array.from(items.values());
}
function mergeForSync(remoteLibrary, localLibrary) {
const remote = normaliseLibrary(remoteLibrary);
const local = normaliseLibrary(localLibrary);
const vaults = mergeSyncItems(remote.vaults, local.vaults, (remoteVault, localVault) => {
const vault = newestByTimestamp(remoteVault, localVault);
vault.folders = mergeSyncItems(remoteVault.folders, localVault.folders, newestByTimestamp);
vault.resources = mergeSyncItems(remoteVault.resources, localVault.resources, (remoteResource, localResource) => {
const resource = newestByTimestamp(remoteResource, localResource);
resource.sessions = mergeSyncItems(remoteResource.sessions || [], localResource.sessions || [], newestByTimestamp);
return resource;
});
return vault;
});
return normaliseLibrary({ schemaVersion: SCHEMA_VERSION, vaults });
}
function progressFor(resource) {
const sessions = Array.isArray(resource.sessions) ? resource.sessions : [];
const furthest = sessions.reduce((maximum, session) => {
const value = Number(session.end);
return Number.isFinite(value) ? Math.max(maximum, value) : maximum;
}, 0);
let total = 100;
if (resource.type === "book") total = Number(resource.pageCount) || 0;
if (resource.type === "video") total = Number(resource.durationMinutes) || 0;
if (!total) return { current: furthest, total: 0, percent: resource.status === "completed" ? 100 : 0 };
const percent = Math.max(0, Math.min(100, Math.round((furthest / total) * 100)));
return { current: furthest, total, percent: resource.status === "completed" ? 100 : percent };
}
function addVault(library, name, colour) {
const data = normaliseLibrary(library);
data.vaults.push(emptyVault(name.trim(), colour));
return data;
}
function renameVault(library, vaultId, name) {
const data = normaliseLibrary(library);
const vault = data.vaults.find((item) => item.id === vaultId);
assert(vault, "Vault not found.");
vault.name = name.trim();
vault.updatedAt = now();
return data;
}
function deleteVault(library, vaultId) {
const data = normaliseLibrary(library);
assert(data.vaults.length > 1, "Keep at least one vault.");
data.vaults = data.vaults.filter((vault) => vault.id !== vaultId);
return data;
}
function addFolder(library, vaultId, name) {
const data = normaliseLibrary(library);
const vault = data.vaults.find((item) => item.id === vaultId);
assert(vault, "Vault not found.");
const stamp = now();
vault.folders.push({ id: id(), name: name.trim(), createdAt: stamp, updatedAt: stamp });
vault.updatedAt = stamp;
return data;
}
function deleteFolder(library, vaultId, folderId) {
const data = normaliseLibrary(library);
const vault = data.vaults.find((item) => item.id === vaultId);
assert(vault, "Vault not found.");
vault.folders = vault.folders.filter((folder) => folder.id !== folderId);
vault.resources.forEach((resource) => {
if (resource.folderId === folderId) resource.folderId = null;
});
vault.updatedAt = now();
return data;
}
function serialise(library) {
const data = normaliseLibrary(library);
data.exportedAt = now();
data.vaults.forEach((vault) => vault.resources.forEach((resource) => {
if (resource.attachmentId) resource.attachmentState = "reattach-required-after-import";
}));
return JSON.stringify(data, null, 2);
}
return {
SCHEMA_VERSION,
TYPES,
FORMATS,
STATUSES,
id,
now,
createSeed,
validateLibrary,
normaliseLibrary,
mergeLibraries,
mergeForSync,
progressFor,
addVault,
renameVault,
deleteVault,
addFolder,
deleteFolder,
serialise,
};
});

View File

@@ -0,0 +1,908 @@
(function () {
"use strict";
const root = document.getElementById("resource-loader");
const Model = globalThis.ResourceLoaderModel;
if (!root || !Model) return;
const DB_NAME = "zxh-resource-loader";
const DB_VERSION = 2;
const ATTACHMENT_STORE = "attachments";
const API_URL = "/api/resource-loader";
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
const state = {
db: null,
library: null,
vaultId: null,
folderId: "all",
view: localStorage.getItem("rl-view") || "list",
importData: null,
nameAction: null,
pendingImport: null,
pdfUrls: new Map(),
thumbnailUrls: new Map(),
uploadedThumbnails: new Set(),
serverEtag: null,
syncQueue: Promise.resolve(),
eventsBound: false,
};
let pdfJsPromise;
const STATUS_GROUPS = [
{ id: "active", label: "In progress", description: "Resources you are actively reading or watching." },
{ id: "backlog", label: "Backlog", description: "Resources you intend to start later." },
{ id: "shelf", label: "On the shelf", description: "Resources kept close at hand, but not currently active." },
{ id: "completed", label: "Completed", description: "Resources you have finished." },
{ id: "archived", label: "Archived", description: "Resources retained outside your current library flow." },
];
const els = {
vaultSelect: $("#rl-vault-select"),
folderList: $("#rl-folder-list"),
resourceList: $("#rl-resource-list"),
contextLabel: $("#rl-context-label"),
search: $("#rl-search"),
typeFilter: $("#rl-filter-type"),
statusFilter: $("#rl-filter-status"),
tagFilter: $("#rl-filter-tag"),
sort: $("#rl-sort"),
resourceDialog: $("#rl-resource-dialog"),
resourceForm: $("#rl-resource-form"),
sessionDialog: $("#rl-session-dialog"),
nameDialog: $("#rl-name-dialog"),
vaultDialog: $("#rl-vault-dialog"),
importDialog: $("#rl-import-dialog"),
storageDialog: $("#rl-storage-dialog"),
pdfInput: $("#rl-pdf-input"),
jsonInput: $("#rl-json-input"),
loading: $("#rl-loading"),
toast: $("#rl-toast"),
syncStatus: $("#rl-sync-status"),
};
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (db.objectStoreNames.contains("metadata")) db.deleteObjectStore("metadata");
if (!db.objectStoreNames.contains(ATTACHMENT_STORE)) db.createObjectStore(ATTACHMENT_STORE, { keyPath: "id" });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function request(storeName, mode, operation) {
return new Promise((resolve, reject) => {
const transaction = state.db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const result = operation(store);
transaction.oncomplete = () => resolve(result && result.result);
transaction.onerror = () => reject(transaction.error || (result && result.error));
transaction.onabort = () => reject(transaction.error || new Error("Storage operation was aborted."));
});
}
function getAttachment(id) { return id ? request(ATTACHMENT_STORE, "readonly", (store) => store.get(id)) : Promise.resolve(null); }
function putAttachment(record) { return request(ATTACHMENT_STORE, "readwrite", (store) => store.put(record)); }
function deleteAttachment(id) { return id ? request(ATTACHMENT_STORE, "readwrite", (store) => store.delete(id)) : Promise.resolve(); }
function deleteStoredThumbnails() {
return request(ATTACHMENT_STORE, "readwrite", (store) => {
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) return;
if (cursor.value && cursor.value.kind === "thumbnail") cursor.delete();
cursor.continue();
};
return cursorRequest;
});
}
function setSyncStatus(status, label) {
if (!els.syncStatus) return;
els.syncStatus.dataset.state = status;
els.syncStatus.querySelector("small").textContent = label;
}
async function responseError(response, prefix) {
let detail = "";
try {
const contentType = response.headers.get("Content-Type") || "";
if (contentType.includes("application/json")) {
const body = await response.json();
detail = body && body.message ? body.message : "";
} else {
detail = (await response.text()).trim().slice(0, 240);
}
} catch (_) {
detail = "";
}
return new Error(`${prefix} (${response.status})${detail ? `: ${detail}` : ""}`);
}
async function fetchServerLibrary() {
const response = await fetch(API_URL, { cache: "no-store", credentials: "same-origin" });
if (response.status === 404) return null;
if (!response.ok) throw await responseError(response, "Server library request failed");
const library = Model.normaliseLibrary(await response.json());
state.serverEtag = response.headers.get("ETag");
return library;
}
async function uploadLocalThumbnails(library) {
const ids = Array.from(new Set(library.vaults.flatMap((vault) => vault.resources.map((resource) => resource.thumbnailId).filter(Boolean))));
await Promise.all(ids.map(async (id) => {
if (state.uploadedThumbnails.has(id)) return;
const attachment = await getAttachment(id);
if (!attachment || !attachment.blob || attachment.kind !== "thumbnail") return;
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, {
method: "PUT",
credentials: "same-origin",
headers: { "Content-Type": attachment.type || attachment.blob.type || "image/jpeg" },
body: attachment.blob,
});
if (!response.ok) throw await responseError(response, "Thumbnail upload failed");
await deleteAttachment(id);
state.uploadedThumbnails.add(id);
}));
await deleteStoredThumbnails();
}
async function pushServerLibrary(library, allowMerge) {
setSyncStatus("syncing", "syncing…");
const headers = { "Content-Type": "application/json" };
if (state.serverEtag) headers["If-Match"] = state.serverEtag;
const response = await fetch(API_URL, {
method: "PUT",
credentials: "same-origin",
headers,
body: JSON.stringify(library),
});
if (response.status === 412 && allowMerge) {
const remote = await fetchServerLibrary();
const merged = Model.mergeForSync(remote, library);
state.library = merged;
if (state.eventsBound) render();
toast("Changes from another device were merged.");
return pushServerLibrary(merged, false);
}
if (!response.ok) throw await responseError(response, "Server save failed");
state.serverEtag = response.headers.get("ETag");
setSyncStatus("synced", "synced to server");
uploadLocalThumbnails(library).catch((error) => {
console.warn("Resource Loader metadata was saved, but a cover could not be uploaded.", error);
toast(`Saved to the server. Cover upload pending: ${error.message}`, true);
});
}
async function saveLibrary() {
state.syncQueue = state.syncQueue.catch(() => {}).then(async () => {
try {
await pushServerLibrary(Model.normaliseLibrary(state.library), true);
return true;
} catch (error) {
console.error("Resource Loader could not save to the server.", error);
setSyncStatus("error", "server unavailable");
toast("The change was not saved. Reloading the shared server library.", true);
try {
const remote = await fetchServerLibrary();
if (remote) {
state.library = remote;
if (state.eventsBound) render();
}
} catch (reloadError) {
console.warn("The authoritative server library could not be reloaded.", reloadError);
}
return false;
}
});
return state.syncQueue;
}
async function loadLibrary() {
const remote = await fetchServerLibrary();
if (!remote) throw new Error("The server Resource Loader library has not been initialised.");
setSyncStatus("synced", "synced to server");
return remote;
}
function currentVault() {
return state.library.vaults.find((vault) => vault.id === state.vaultId) || state.library.vaults[0];
}
function currentResource(id) {
return currentVault().resources.find((resource) => resource.id === id);
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
function titleCase(value) {
return String(value || "").replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function splitList(value) {
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
}
function showLoading(message) {
els.loading.querySelector("p").textContent = message || "Working…";
els.loading.classList.add("is-visible");
}
function hideLoading() { els.loading.classList.remove("is-visible"); }
let toastTimer;
function toast(message, error) {
clearTimeout(toastTimer);
els.toast.textContent = message;
els.toast.classList.toggle("is-error", Boolean(error));
els.toast.classList.add("is-visible");
toastTimer = setTimeout(() => els.toast.classList.remove("is-visible"), 3600);
}
function closeDialog(dialog) {
if (dialog && dialog.open) dialog.close();
}
function setValue(id, value) {
const element = document.getElementById(id);
if (element) element.value = value == null ? "" : value;
}
function renderVaultSelect() {
const previous = state.vaultId;
els.vaultSelect.innerHTML = state.library.vaults.map((vault) =>
`<option value="${escapeHtml(vault.id)}">${escapeHtml(vault.name)}</option>`).join("");
state.vaultId = state.library.vaults.some((vault) => vault.id === previous) ? previous : state.library.vaults[0].id;
els.vaultSelect.value = state.vaultId;
}
function resourceCount(folderId) {
const resources = currentVault().resources;
if (folderId === "all") return resources.length;
if (folderId === "unfiled") return resources.filter((item) => !item.folderId).length;
return resources.filter((item) => item.folderId === folderId).length;
}
function folderRow(id, label, editable) {
return `<div class="rl-folder-row${state.folderId === id ? " is-active" : ""}" data-folder-row="${escapeHtml(id)}">
<button type="button" data-folder="${escapeHtml(id)}"><span>${escapeHtml(label)}</span><span>${resourceCount(id)}</span></button>
${editable ? `<button type="button" class="rl-folder-menu" data-folder-menu="${escapeHtml(id)}" aria-label="Manage ${escapeHtml(label)}">•••</button>` : ""}
</div>`;
}
function renderFolders() {
const vault = currentVault();
if (!vault.folders.some((folder) => folder.id === state.folderId) && !["all", "unfiled"].includes(state.folderId)) state.folderId = "all";
els.folderList.innerHTML = folderRow("all", "All resources", false) + folderRow("unfiled", "Unfiled", false) +
'<div class="rl-folder-rule" aria-hidden="true"></div>' +
vault.folders.slice().sort((a, b) => a.name.localeCompare(b.name)).map((folder) => folderRow(folder.id, folder.name, true)).join("");
els.folderList.querySelectorAll("[data-folder]").forEach((button) => button.addEventListener("click", () => {
state.folderId = button.dataset.folder;
render();
}));
els.folderList.querySelectorAll("[data-folder-menu]").forEach((button) => button.addEventListener("click", () => manageFolder(button.dataset.folderMenu)));
}
function populateTagFilter() {
const previous = els.tagFilter.value;
const tags = Array.from(new Set(currentVault().resources.flatMap((resource) => resource.tags || []))).sort((a, b) => a.localeCompare(b));
els.tagFilter.innerHTML = '<option value="">All tags</option>' + tags.map((tag) => `<option value="${escapeHtml(tag)}">${escapeHtml(tag)}</option>`).join("");
els.tagFilter.value = tags.includes(previous) ? previous : "";
}
function filteredResources() {
const query = els.search.value.trim().toLocaleLowerCase();
let resources = currentVault().resources.filter((resource) => {
if (state.folderId === "unfiled" && resource.folderId) return false;
if (!["all", "unfiled"].includes(state.folderId) && resource.folderId !== state.folderId) return false;
if (els.typeFilter.value && resource.type !== els.typeFilter.value) return false;
if (els.statusFilter.value && resource.status !== els.statusFilter.value) return false;
if (els.tagFilter.value && !(resource.tags || []).includes(els.tagFilter.value)) return false;
const haystack = [resource.title, ...(resource.creators || []), ...(resource.tags || []), resource.description, resource.notes].join(" ").toLocaleLowerCase();
return !query || haystack.includes(query);
});
resources.sort((a, b) => {
if (els.sort.value === "title") return a.title.localeCompare(b.title);
if (els.sort.value === "progress") return Model.progressFor(b).percent - Model.progressFor(a).percent;
return String(b.updatedAt).localeCompare(String(a.updatedAt));
});
return resources;
}
async function thumbnailUrl(id) {
if (!id) return null;
if (state.thumbnailUrls.has(id)) return state.thumbnailUrls.get(id);
const attachment = await getAttachment(id);
let blob = attachment && attachment.blob;
if (!blob) {
try {
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, { cache: "no-store", credentials: "same-origin" });
if (response.ok) blob = await response.blob();
} catch (error) {
console.warn(`Thumbnail ${id} is unavailable.`, error);
}
}
if (!blob) return null;
const url = URL.createObjectURL(blob);
state.thumbnailUrls.set(id, url);
return url;
}
function cardMarkup(resource) {
const progress = Model.progressFor(resource);
const initial = (resource.title || "?").trim().charAt(0).toUpperCase();
const creators = (resource.creators || []).join(", ") || "Unknown creator";
return `<article class="rl-resource-card rl-resource-card--${escapeHtml(resource.status)}" tabindex="0" role="button" data-resource-id="${escapeHtml(resource.id)}" aria-label="Open ${escapeHtml(resource.title)}">
<span class="rl-resource-cover" data-thumb="${escapeHtml(resource.thumbnailId || "")}">${escapeHtml(initial)}</span>
<span class="rl-resource-title"><strong>${escapeHtml(resource.title)}</strong><small>${escapeHtml(creators)}</small><small>${escapeHtml((resource.tags || []).slice(0, 3).join(" · "))}</small></span>
<span class="rl-badges"><span class="rl-badge">${escapeHtml(resource.type)}</span><span class="rl-badge">${escapeHtml(resource.status)}</span><span class="rl-badge">${escapeHtml(resource.format)}</span></span>
<span class="rl-progress"><span class="rl-progress__track"><span class="rl-progress__fill" style="width:${progress.percent}%"></span></span><small>${progress.percent}% complete</small></span>
<span class="rl-card-arrow" aria-hidden="true"></span>
</article>`;
}
function statusGroupMarkup(group, resources) {
const headingId = `rl-status-${group.id}`;
const countLabel = `${resources.length} ${resources.length === 1 ? "resource" : "resources"}`;
return `<section class="rl-status-group rl-status-group--${group.id}" aria-labelledby="${headingId}">
<header class="rl-status-group__head">
<span class="rl-status-group__marker" aria-hidden="true"></span>
<span class="rl-status-group__title"><strong id="${headingId}">${escapeHtml(group.label)}</strong><small>${escapeHtml(group.description)}</small></span>
<span class="rl-status-group__count" aria-label="${countLabel}">${resources.length}</span>
</header>
<div class="rl-status-resources">${resources.map(cardMarkup).join("")}</div>
</section>`;
}
async function hydrateThumbnails(container) {
const targets = Array.from(container.querySelectorAll("[data-thumb]")).filter((item) => item.dataset.thumb);
await Promise.all(targets.map(async (target) => {
const url = await thumbnailUrl(target.dataset.thumb);
if (url && target.isConnected) target.innerHTML = `<img src="${url}" alt="" />`;
}));
}
function bindResourceCards() {
els.resourceList.querySelectorAll("[data-resource-id]").forEach((card) => {
const open = () => openResourceDialog(currentResource(card.dataset.resourceId));
card.addEventListener("click", open);
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); open(); }
});
});
}
function renderResources() {
const resources = filteredResources();
const folder = currentVault().folders.find((item) => item.id === state.folderId);
els.contextLabel.textContent = state.folderId === "all" ? "All resources" : state.folderId === "unfiled" ? "Unfiled" : folder ? folder.name : "All resources";
els.resourceList.classList.toggle("is-grid", state.view === "grid");
$("#rl-view-list").classList.toggle("is-active", state.view === "list");
$("#rl-view-grid").classList.toggle("is-active", state.view === "grid");
$("#rl-view-list").setAttribute("aria-pressed", String(state.view === "list"));
$("#rl-view-grid").setAttribute("aria-pressed", String(state.view === "grid"));
if (!resources.length) {
const filtered = currentVault().resources.length > 0;
els.resourceList.innerHTML = `<div class="rl-empty"><span class="rl-empty__mark">⌑</span><h3>${filtered ? "Nothing matches this view" : "Your shelf is ready"}</h3><p>${filtered ? "Try changing the folder, filters, or search." : "Add a physical book, save a link, or import a PDF to begin."}</p><button type="button" class="rl-primary" data-empty-add>Add resource</button></div>`;
els.resourceList.querySelector("[data-empty-add]").addEventListener("click", () => openResourceDialog());
return;
}
els.resourceList.innerHTML = STATUS_GROUPS.map((group) => {
const matching = resources.filter((resource) => resource.status === group.id);
return matching.length ? statusGroupMarkup(group, matching) : "";
}).join("");
bindResourceCards();
hydrateThumbnails(els.resourceList);
}
function renderStats() {
const resources = currentVault().resources;
$("#rl-stat-total").textContent = resources.length;
$("#rl-stat-active").textContent = resources.filter((item) => item.status === "active").length;
$("#rl-stat-backlog").textContent = resources.filter((item) => ["backlog", "shelf"].includes(item.status)).length;
$("#rl-stat-completed").textContent = resources.filter((item) => item.status === "completed").length;
}
function render() {
renderVaultSelect();
renderFolders();
populateTagFilter();
renderStats();
renderResources();
}
function populateFolderField(selected) {
const field = $("#rl-field-folder");
field.innerHTML = '<option value="">Unfiled</option>' + currentVault().folders.slice().sort((a, b) => a.name.localeCompare(b.name)).map((folder) => `<option value="${escapeHtml(folder.id)}">${escapeHtml(folder.name)}</option>`).join("");
field.value = selected || "";
}
function resetCover() {
$("#rl-cover-preview").innerHTML = "<span>Cover</span>";
}
function updateResourceFormForType() {
const type = $("#rl-field-type").value;
const isBook = type === "book";
els.resourceDialog.classList.toggle("is-compact", !isBook);
els.resourceDialog.querySelectorAll("[data-resource-types]").forEach((element) => {
element.hidden = !element.dataset.resourceTypes.split(/\s+/).includes(type);
});
$("#rl-creators-label").textContent = type === "video" ? "Presenter / creator" : "Author(s)";
$("#rl-publisher-label").textContent = type === "video" ? "Channel" : "Publisher";
if (!isBook) $("#rl-field-format").value = "web";
if (!isBook) $("#rl-field-pages").value = "";
if (type !== "video") $("#rl-field-duration").value = "";
}
async function showCover(id) {
resetCover();
const url = await thumbnailUrl(id);
if (url) $("#rl-cover-preview").innerHTML = `<img src="${url}" alt="Imported PDF cover" />`;
}
function renderSessions(resource) {
const section = $("#rl-session-section");
if (!resource) { section.hidden = true; return; }
section.hidden = false;
const sessions = (resource.sessions || []).slice().sort((a, b) => String(b.date).localeCompare(String(a.date)));
$("#rl-session-list").innerHTML = sessions.length ? sessions.map((session) => `<div class="rl-session-row">
<time datetime="${escapeHtml(session.date)}">${escapeHtml(session.date)}</time>
<strong>${escapeHtml(session.start)}${escapeHtml(session.end)} ${escapeHtml(session.unit)}</strong>
<span>${escapeHtml(session.note || "")}</span>
<button type="button" class="rl-danger" data-delete-session="${escapeHtml(session.id)}">Delete</button>
</div>`).join("") : '<p class="rl-help">No sessions logged yet.</p>';
$("#rl-session-list").querySelectorAll("[data-delete-session]").forEach((button) => button.addEventListener("click", async () => {
if (!confirm("Delete this session?")) return;
resource.sessions = resource.sessions.filter((item) => item.id !== button.dataset.deleteSession);
resource.updatedAt = Model.now();
if (!await saveLibrary()) return;
renderSessions(resource);
render();
toast("Session deleted.");
}));
}
async function openResourceDialog(resource, draft) {
els.resourceForm.reset();
resetCover();
const item = resource || draft || null;
state.pendingImport = draft ? { attachmentId: draft.attachmentId, thumbnailId: draft.thumbnailId } : null;
$("#rl-resource-dialog-title").textContent = resource ? "Edit resource" : draft ? "Review imported PDF" : "Add resource";
setValue("rl-resource-id", resource ? resource.id : "");
setValue("rl-attachment-id", item && item.attachmentId);
setValue("rl-thumbnail-id", item && item.thumbnailId);
setValue("rl-field-title", item && item.title);
setValue("rl-field-creators", item && (item.creators || []).join(", "));
setValue("rl-field-type", item ? item.type : "book");
setValue("rl-field-format", item ? item.format : "physical");
setValue("rl-field-status", item ? item.status : "backlog");
setValue("rl-field-pages", item && item.pageCount);
setValue("rl-field-duration", item && item.durationMinutes);
setValue("rl-field-url", item && item.url);
setValue("rl-field-publisher", item && item.publisher);
setValue("rl-field-published", item && item.published);
setValue("rl-field-tags", item && (item.tags || []).join(", "));
setValue("rl-field-description", item && item.description);
setValue("rl-field-notes", item && item.notes);
updateResourceFormForType();
populateFolderField(item && item.folderId ? item.folderId : (!["all", "unfiled"].includes(state.folderId) ? state.folderId : ""));
$("#rl-delete-resource").hidden = !resource;
$("#rl-open-resource").hidden = !(item && (item.url || item.attachmentId));
renderSessions(resource);
if (item && item.thumbnailId) showCover(item.thumbnailId);
els.resourceDialog.showModal();
$("#rl-field-title").focus();
}
function formResource(existing) {
const stamp = Model.now();
return {
id: existing ? existing.id : Model.id(),
title: $("#rl-field-title").value.trim(),
creators: splitList($("#rl-field-creators").value),
type: $("#rl-field-type").value,
format: $("#rl-field-format").value,
status: $("#rl-field-status").value,
folderId: $("#rl-field-folder").value || null,
pageCount: Number($("#rl-field-pages").value) || null,
durationMinutes: Number($("#rl-field-duration").value) || null,
url: $("#rl-field-url").value.trim(),
publisher: $("#rl-field-publisher").value.trim(),
published: $("#rl-field-published").value.trim(),
tags: splitList($("#rl-field-tags").value),
description: $("#rl-field-description").value.trim(),
notes: $("#rl-field-notes").value.trim(),
attachmentId: $("#rl-attachment-id").value || null,
thumbnailId: $("#rl-thumbnail-id").value || null,
sessions: existing ? existing.sessions || [] : [],
createdAt: existing ? existing.createdAt : stamp,
updatedAt: stamp,
};
}
async function saveResource(event) {
event.preventDefault();
const vault = currentVault();
const existing = currentResource($("#rl-resource-id").value);
const resource = formResource(existing);
if (!resource.title) return;
if (existing) vault.resources[vault.resources.findIndex((item) => item.id === existing.id)] = resource;
else vault.resources.push(resource);
vault.updatedAt = Model.now();
if (!await saveLibrary()) return;
state.pendingImport = null;
closeDialog(els.resourceDialog);
render();
toast(existing ? "Resource updated." : "Resource added.");
}
async function removeResource() {
const resource = currentResource($("#rl-resource-id").value);
if (!resource || !confirm(`Delete “${resource.title}”? This also removes its retained PDF.`)) return;
const attachmentIds = [resource.attachmentId, resource.thumbnailId];
currentVault().resources = currentVault().resources.filter((item) => item.id !== resource.id);
currentVault().updatedAt = Model.now();
if (!await saveLibrary()) return;
await Promise.all(attachmentIds.map(deleteAttachment));
closeDialog(els.resourceDialog);
render();
toast("Resource deleted.");
}
function openSessionDialog() {
const resource = currentResource($("#rl-resource-id").value);
if (!resource) return;
const labels = resource.type === "book" ? ["Start page", "End page", "pages"] : resource.type === "video" ? ["Start minute", "End minute", "minutes"] : ["Start percentage", "End percentage", "percent"];
$("#rl-session-start-label").textContent = labels[0];
$("#rl-session-end-label").textContent = labels[1];
$("#rl-session-form").dataset.unit = labels[2];
$("#rl-session-form").reset();
$("#rl-session-date").value = new Date().toISOString().slice(0, 10);
const progress = Model.progressFor(resource);
$("#rl-session-start").value = progress.current || 0;
els.sessionDialog.showModal();
}
async function saveSession(event) {
event.preventDefault();
const resource = currentResource($("#rl-resource-id").value);
if (!resource) return;
const start = Number($("#rl-session-start").value);
const end = Number($("#rl-session-end").value);
if (end < start) { toast("The end position must be at or after the start.", true); return; }
if (resource.type === "article" && end > 100) { toast("Article progress cannot exceed 100%.", true); return; }
resource.sessions.push({ id: Model.id(), date: $("#rl-session-date").value, start, end, unit: event.currentTarget.dataset.unit, note: $("#rl-session-note").value.trim(), createdAt: Model.now() });
resource.updatedAt = Model.now();
if (Model.progressFor(resource).percent >= 100) resource.status = "completed";
else if (["backlog", "shelf"].includes(resource.status)) resource.status = "active";
if (!await saveLibrary()) return;
closeDialog(els.sessionDialog);
renderSessions(resource);
render();
toast("Session logged.");
}
async function openLinkedResource() {
const resource = currentResource($("#rl-resource-id").value);
if (!resource) return;
if (resource.attachmentId) {
const record = await getAttachment(resource.attachmentId);
if (!record || !record.blob) { toast("This PDF is not attached in this browser. Import it again to restore the file.", true); return; }
if (state.pdfUrls.has(resource.attachmentId)) URL.revokeObjectURL(state.pdfUrls.get(resource.attachmentId));
const url = URL.createObjectURL(record.blob);
state.pdfUrls.set(resource.attachmentId, url);
window.open(url, "_blank", "noopener");
return;
}
if (resource.url) window.open(resource.url, "_blank", "noopener");
}
function openNameDialog(action, title, value, colour) {
state.nameAction = action;
$("#rl-name-title").textContent = title;
$("#rl-name-input").value = value || "";
$("#rl-colour-field").hidden = !String(action).includes("vault");
$("#rl-name-colour").value = colour || "#9b6b43";
$("#rl-name-delete").hidden = !String(action).startsWith("rename-folder:");
els.nameDialog.showModal();
$("#rl-name-input").focus();
}
async function submitName(event) {
event.preventDefault();
const name = $("#rl-name-input").value.trim();
if (!name) return;
const action = state.nameAction || "";
if (action === "add-folder") state.library = Model.addFolder(state.library, state.vaultId, name);
if (action.startsWith("rename-folder:")) {
const folder = currentVault().folders.find((item) => item.id === action.split(":")[1]);
folder.name = name; folder.updatedAt = Model.now(); currentVault().updatedAt = Model.now();
}
if (action === "add-vault") {
state.library = Model.addVault(state.library, name, $("#rl-name-colour").value);
state.vaultId = state.library.vaults[state.library.vaults.length - 1].id;
state.folderId = "all";
}
if (action.startsWith("rename-vault:")) {
state.library = Model.renameVault(state.library, action.split(":")[1], name);
const vault = state.library.vaults.find((item) => item.id === action.split(":")[1]);
vault.colour = $("#rl-name-colour").value;
}
if (!await saveLibrary()) return;
closeDialog(els.nameDialog);
render();
renderVaultManager();
toast("Saved.");
}
function manageFolder(folderId) {
const folder = currentVault().folders.find((item) => item.id === folderId);
if (!folder) return;
openNameDialog(`rename-folder:${folderId}`, "Manage folder", folder.name);
}
async function deleteNamedFolder() {
const action = state.nameAction || "";
if (!action.startsWith("rename-folder:")) return;
const folderId = action.split(":")[1];
const folder = currentVault().folders.find((item) => item.id === folderId);
if (!folder || !confirm(`Delete “${folder.name}”? Its resources will move to Unfiled.`)) return;
state.library = Model.deleteFolder(state.library, state.vaultId, folderId);
if (!await saveLibrary()) return;
closeDialog(els.nameDialog);
render();
toast("Folder deleted; resources moved to Unfiled.");
}
async function cleanupPendingImport() {
const pending = state.pendingImport;
if (!pending) return;
state.pendingImport = null;
await Promise.all([deleteAttachment(pending.attachmentId), deleteAttachment(pending.thumbnailId)]).catch(() => {});
}
function renderVaultManager() {
$("#rl-vault-list").innerHTML = state.library.vaults.map((vault) => `<div class="rl-vault-row">
<span class="rl-vault-dot" style="--vault-colour:${escapeHtml(vault.colour)}"></span><strong>${escapeHtml(vault.name)}</strong>
<button type="button" data-rename-vault="${escapeHtml(vault.id)}">Rename</button>
<button type="button" class="rl-danger" data-delete-vault="${escapeHtml(vault.id)}" ${state.library.vaults.length === 1 ? "disabled" : ""}>Delete</button>
</div>`).join("");
$("#rl-vault-list").querySelectorAll("[data-rename-vault]").forEach((button) => button.addEventListener("click", () => {
const vault = state.library.vaults.find((item) => item.id === button.dataset.renameVault);
closeDialog(els.vaultDialog);
openNameDialog(`rename-vault:${vault.id}`, "Rename vault", vault.name, vault.colour);
}));
$("#rl-vault-list").querySelectorAll("[data-delete-vault]").forEach((button) => button.addEventListener("click", async () => {
const vault = state.library.vaults.find((item) => item.id === button.dataset.deleteVault);
if (!confirm(`Delete the “${vault.name}” vault and all of its records? Retained PDFs will also be removed.`)) return;
const attachmentIds = vault.resources.flatMap((resource) => [resource.attachmentId, resource.thumbnailId]);
state.library = Model.deleteVault(state.library, vault.id);
state.vaultId = state.library.vaults[0].id;
state.folderId = "all";
if (!await saveLibrary()) return;
await Promise.all(attachmentIds.map(deleteAttachment));
renderVaultManager(); render(); toast("Vault deleted.");
}));
}
function exportJson() {
const blob = new Blob([Model.serialise(state.library)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `resource-loader-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast("JSON backup exported. PDF files are not included.");
}
async function readJsonFile(file) {
try {
const data = JSON.parse(await file.text());
Model.validateLibrary(data);
state.importData = Model.normaliseLibrary(data);
const vaults = state.importData.vaults.length;
const folders = state.importData.vaults.reduce((sum, vault) => sum + vault.folders.length, 0);
const resources = state.importData.vaults.reduce((sum, vault) => sum + vault.resources.length, 0);
$("#rl-import-summary").innerHTML = `<div><strong>${vaults}</strong><span>vaults</span></div><div><strong>${folders}</strong><span>folders</span></div><div><strong>${resources}</strong><span>resources</span></div>`;
els.importDialog.showModal();
} catch (error) {
toast(`Import rejected: ${error.message}`, true);
} finally {
els.jsonInput.value = "";
}
}
async function applyImport(mode) {
if (!state.importData) return;
if (mode === "replace" && !confirm("Replace every vault and resource? Retained PDF files will also be cleared.")) return;
state.library = mode === "merge" ? Model.mergeLibraries(state.library, state.importData) : state.importData;
state.vaultId = state.library.vaults[0] && state.library.vaults[0].id;
state.folderId = "all";
if (!await saveLibrary()) return;
if (mode === "replace") await request(ATTACHMENT_STORE, "readwrite", (store) => store.clear());
state.importData = null;
closeDialog(els.importDialog);
render();
toast(mode === "merge" ? "Library merged." : "Library replaced.");
}
function cleanPdfTitle(filename) {
return filename.replace(/\.pdf$/i, "").replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w/g, (letter) => letter.toUpperCase());
}
async function pdfCover(page) {
const viewport = page.getViewport({ scale: 0.7 });
const canvas = document.createElement("canvas");
const maximum = 420;
const scale = Math.min(1, maximum / viewport.width);
const scaled = page.getViewport({ scale: 0.7 * scale });
canvas.width = Math.ceil(scaled.width);
canvas.height = Math.ceil(scaled.height);
await page.render({ canvasContext: canvas.getContext("2d"), viewport: scaled }).promise;
return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", .78));
}
function loadPdfJs() {
if (globalThis.pdfjsLib) return Promise.resolve(globalThis.pdfjsLib);
if (pdfJsPromise) return pdfJsPromise;
pdfJsPromise = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "/assets/scripts/vendor/pdf.min.js";
script.async = true;
script.onload = () => globalThis.pdfjsLib ? resolve(globalThis.pdfjsLib) : reject(new Error("PDF.js loaded without exposing its API."));
script.onerror = () => reject(new Error("The local PDF.js library could not be loaded."));
document.head.appendChild(script);
});
return pdfJsPromise;
}
async function importPdf(file) {
if (!file || (file.type && file.type !== "application/pdf") || !file.name.toLowerCase().endsWith(".pdf")) {
toast("Choose a PDF file.", true); return;
}
showLoading("Reading PDF metadata…");
let attachmentId;
let thumbnailId;
try {
const pdfjs = await loadPdfJs();
pdfjs.GlobalWorkerOptions.workerSrc = "/assets/scripts/vendor/pdf.worker.min.js";
const bytes = new Uint8Array(await file.arrayBuffer());
const documentTask = pdfjs.getDocument({ data: bytes });
const pdf = await documentTask.promise;
const metadata = await pdf.getMetadata().catch(() => ({ info: {}, metadata: null }));
const info = metadata.info || {};
attachmentId = Model.id();
await putAttachment({ id: attachmentId, kind: "pdf", name: file.name, type: file.type || "application/pdf", size: file.size, blob: file, createdAt: Model.now() });
const cover = await pdfCover(await pdf.getPage(1)).catch(() => null);
if (cover) {
thumbnailId = Model.id();
await putAttachment({ id: thumbnailId, kind: "thumbnail", name: `${file.name}.jpg`, type: "image/jpeg", size: cover.size, blob: cover, createdAt: Model.now() });
}
const keywords = Array.isArray(info.Keywords) ? info.Keywords : splitList(info.Keywords || "");
const draft = {
title: String(info.Title || "").trim() || cleanPdfTitle(file.name),
creators: splitList(info.Author || ""),
type: "book", format: "pdf", status: "shelf", pageCount: pdf.numPages,
description: String(info.Subject || "").trim(), tags: keywords,
publisher: String(info.Producer || info.Creator || "").trim(),
published: String(info.CreationDate || "").replace(/^D:/, "").slice(0, 8),
notes: "", attachmentId, thumbnailId: thumbnailId || null,
};
await pdf.destroy();
hideLoading();
await openResourceDialog(null, draft);
toast("PDF metadata loaded. Review it before saving.");
} catch (error) {
hideLoading();
if (attachmentId) await deleteAttachment(attachmentId).catch(() => {});
if (thumbnailId) await deleteAttachment(thumbnailId).catch(() => {});
const reason = error && error.name === "PasswordException" ? "The PDF is password protected." : error && error.name === "QuotaExceededError" ? "Browser storage is full." : "The PDF could not be read.";
const detail = error && error.message ? ` ${error.message}` : "";
toast(`${reason}${detail} No resource was created.`, true);
console.error(error);
} finally {
els.pdfInput.value = "";
}
}
async function showStorage() {
els.storageDialog.showModal();
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
const used = estimate.usage || 0;
const quota = estimate.quota || 0;
const format = (bytes) => bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
$("#rl-storage-summary").textContent = `${format(used)} used of approximately ${format(quota)} available to this site.`;
} else $("#rl-storage-summary").textContent = "This browser does not report storage estimates.";
}
async function requestPersistence() {
if (!navigator.storage || !navigator.storage.persist) { toast("Persistent storage is not supported by this browser.", true); return; }
const granted = await navigator.storage.persist();
toast(granted ? "Persistent storage granted." : "The browser did not grant persistent storage.", !granted);
}
async function clearData() {
if (!confirm("Remove retained PDFs from this browser? Shared server metadata and thumbnails will remain available.")) return;
closeDialog(els.storageDialog);
state.db.close();
await new Promise((resolve, reject) => {
const deletion = indexedDB.deleteDatabase(DB_NAME);
deletion.onsuccess = resolve; deletion.onerror = () => reject(deletion.error); deletion.onblocked = resolve;
});
localStorage.removeItem("rl-view");
location.reload();
}
function bindEvents() {
state.eventsBound = true;
els.vaultSelect.addEventListener("change", () => { state.vaultId = els.vaultSelect.value; state.folderId = "all"; render(); });
[els.search, els.typeFilter, els.statusFilter, els.tagFilter, els.sort].forEach((control) => control.addEventListener(control === els.search ? "input" : "change", renderResources));
$("#rl-view-list").addEventListener("click", () => { state.view = "list"; localStorage.setItem("rl-view", state.view); renderResources(); });
$("#rl-view-grid").addEventListener("click", () => { state.view = "grid"; localStorage.setItem("rl-view", state.view); renderResources(); });
$("#rl-add-resource").addEventListener("click", () => openResourceDialog());
$("#rl-add-folder").addEventListener("click", () => openNameDialog("add-folder", "Add folder"));
$("#rl-manage-vaults").addEventListener("click", () => { renderVaultManager(); els.vaultDialog.showModal(); });
$("#rl-add-vault").addEventListener("click", () => { closeDialog(els.vaultDialog); openNameDialog("add-vault", "Add vault", "", "#9b6b43"); });
els.resourceForm.addEventListener("submit", saveResource);
$("#rl-delete-resource").addEventListener("click", removeResource);
$("#rl-open-resource").addEventListener("click", openLinkedResource);
$("#rl-add-session").addEventListener("click", openSessionDialog);
$("#rl-field-type").addEventListener("change", updateResourceFormForType);
$("#rl-session-form").addEventListener("submit", saveSession);
$("#rl-name-form").addEventListener("submit", submitName);
$("#rl-name-delete").addEventListener("click", deleteNamedFolder);
$("#rl-import-pdf").addEventListener("click", () => els.pdfInput.click());
els.pdfInput.addEventListener("change", () => importPdf(els.pdfInput.files[0]));
$("#rl-import-json").addEventListener("click", () => els.jsonInput.click());
els.jsonInput.addEventListener("change", () => readJsonFile(els.jsonInput.files[0]));
$("#rl-export-json").addEventListener("click", exportJson);
$("#rl-import-merge").addEventListener("click", () => applyImport("merge"));
$("#rl-import-replace").addEventListener("click", () => applyImport("replace"));
$("#rl-storage-button").addEventListener("click", showStorage);
$("#rl-request-persistence").addEventListener("click", requestPersistence);
$("#rl-clear-data").addEventListener("click", clearData);
$$('[data-close-dialog]').forEach((button) => button.addEventListener("click", () => closeDialog(button.closest("dialog"))));
$$('dialog').forEach((dialog) => dialog.addEventListener("click", (event) => { if (event.target === dialog) closeDialog(dialog); }));
els.resourceDialog.addEventListener("close", cleanupPendingImport);
window.addEventListener("beforeunload", () => {
state.pdfUrls.forEach((url) => URL.revokeObjectURL(url));
state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url));
});
}
async function initialise() {
showLoading("Opening your library…");
try {
state.db = await openDatabase();
state.library = await loadLibrary();
state.vaultId = state.library.vaults[0].id;
bindEvents();
render();
uploadLocalThumbnails(state.library).catch((error) => {
console.warn("Some generated covers could not be uploaded yet.", error);
toast("The library is synced, but a generated cover could not be uploaded.", true);
});
} catch (error) {
console.error(error);
setSyncStatus("error", "server unavailable");
root.innerHTML = `<div class="rl-empty"><span class="rl-empty__mark">!</span><h3>Server library is unavailable</h3><p>${escapeHtml(error.message || "The shared Resource Loader library could not be loaded.")}</p></div>`;
} finally {
hideLoading();
}
}
initialise();
})();

View File

@@ -0,0 +1,398 @@
/**
* sitemap-interactive.js
* Drop into /assets/scripts/ — no .org changes required.
*
* Handles two page structures generated by org-publish:
* 1. FLAT LIST — a top-level <ul class="org-ul"> (e.g. Sitemap)
* 2. OUTLINE — <div class="outline-2/3"> with <h2>/<h3> headings and
* nested <ul class="org-ul"> (e.g. "2025 List", "Blogs List")
*
* Activated on any page whose <h1 class="title"> matches SITEMAP_PATTERNS.
*/
(function () {
"use strict";
const SITEMAP_PATTERNS = ["sitemap", "list"];
function init() {
const titleEl = document.querySelector("h1.title");
if (!titleEl) return;
const title = titleEl.textContent.trim().toLowerCase();
if (!SITEMAP_PATTERNS.some((p) => title.includes(p))) return;
const contentDiv = document.getElementById("content");
if (!contentDiv) return;
const hasOutline = !!contentDiv.querySelector(".outline-2, .outline-3");
const topUl = !hasOutline && contentDiv.querySelector("ul.org-ul");
if (!hasOutline && !topUl) return;
// ── Parsers ───────────────────────────────────────────────────────────
function parseLi(li) {
const link = li.querySelector(":scope > a");
const childUl = li.querySelector(":scope > ul");
const textNode = Array.from(li.childNodes).find(
(n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim()
);
const tags = Array.from(li.querySelectorAll(".post-tag")).map((t) =>
t.textContent.trim()
);
const dateEl = li.querySelector(".post-date");
const date = dateEl ? dateEl.textContent.trim() : null;
return {
label: link
? link.textContent.trim()
: textNode
? textNode.textContent.trim()
: li.childNodes[0]?.textContent?.trim() || "",
href: link ? link.getAttribute("href") : null,
tags,
date,
children: childUl ? parseUl(childUl) : [],
};
}
function parseUl(ul) {
return Array.from(ul.children)
.filter((li) => li.tagName === "LI")
.map(parseLi);
}
function parseFlatList() {
return { tree: parseUl(topUl), replaceTarget: topUl, wrapOutline: false };
}
function parseOutline() {
const tree = [];
const outline2s = contentDiv.querySelectorAll(":scope .outline-2");
if (outline2s.length === 0) {
// Only outline-3 directly (flat month grouping without year wrapper)
contentDiv.querySelectorAll(":scope .outline-3").forEach((section) => {
const heading = section.querySelector("h3");
const ul = section.querySelector("ul.org-ul");
tree.push({
label: heading ? heading.textContent.trim() : "Section",
href: null, tags: [], date: null,
children: ul ? parseUl(ul) : [],
});
});
} else {
outline2s.forEach((o2) => {
const h2 = o2.querySelector(":scope > div > h2, :scope > h2");
const groupNode = {
label: h2 ? h2.textContent.trim() : "Group",
href: null, tags: [], date: null,
children: [],
};
const outline3s = o2.querySelectorAll(".outline-3");
if (outline3s.length > 0) {
outline3s.forEach((o3) => {
const h3 = o3.querySelector(":scope > div > h3, :scope > h3");
const ul = o3.querySelector("ul.org-ul");
groupNode.children.push({
label: h3 ? h3.textContent.trim() : "Month",
href: null, tags: [], date: null,
children: ul ? parseUl(ul) : [],
});
});
} else {
const ul = o2.querySelector("ul.org-ul");
if (ul) groupNode.children = parseUl(ul);
}
tree.push(groupNode);
});
}
return { tree, replaceTarget: null, wrapOutline: true };
}
const parsed = hasOutline ? parseOutline() : parseFlatList();
const { tree } = parsed;
if (!tree.length) return;
// ── Styles ────────────────────────────────────────────────────────────
if (!document.getElementById("sm-styles")) {
const style = document.createElement("style");
style.id = "sm-styles";
style.textContent = `
.sitemap-interactive { font-family: inherit; margin: 1.5rem 0; }
.sm-toolbar {
display: flex; align-items: center; gap: .6rem;
margin-bottom: 1rem; flex-wrap: wrap;
}
.sm-search {
flex: 1; min-width: 160px; padding: .35rem .7rem;
border: 1px solid var(--border, #444); border-radius: 4px;
background: var(--bg, transparent); color: inherit; font-size: .9rem;
}
.sm-search:focus { outline: 2px solid var(--accent, #7aa2f7); outline-offset: 1px; }
.sm-expand-all, .sm-collapse-all {
padding: .3rem .65rem; border: 1px solid var(--border, #444);
border-radius: 4px; background: transparent; color: inherit;
font-size: .8rem; cursor: pointer; opacity: .75; transition: opacity .15s;
}
.sm-expand-all:hover, .sm-collapse-all:hover { opacity: 1; }
.sm-tree ul { list-style: none; margin: 0; padding: 0 0 0 1.4rem; }
.sm-tree > ul { padding-left: 0; }
.sm-tree li { margin: 0; }
.sm-node {
display: flex; align-items: center; gap: .4rem;
padding: .2rem .3rem; border-radius: 4px;
line-height: 1.5; transition: background .1s; flex-wrap: wrap;
}
.sm-node:hover { background: var(--hover-bg, rgba(122,162,247,.08)); }
.sm-node.sm-hidden { display: none; }
.sm-toggle {
width: 1.2rem; height: 1.2rem; display: inline-flex;
align-items: center; justify-content: center;
cursor: pointer; border: none; background: none; color: inherit;
font-size: .7rem; opacity: .6;
transition: transform .18s, opacity .15s;
flex-shrink: 0; padding: 0; border-radius: 3px;
}
.sm-toggle:hover { opacity: 1; background: var(--hover-bg, rgba(122,162,247,.15)); }
.sm-toggle.open { transform: rotate(90deg); }
.sm-toggle-placeholder { width: 1.2rem; flex-shrink: 0; }
.sm-icon { font-size: .8rem; opacity: .5; flex-shrink: 0; }
.sm-label a { color: var(--link, inherit); text-decoration: none; font-size: .9rem; }
.sm-label a:hover { text-decoration: underline; }
.sm-label span { font-size: .9rem; font-weight: 600; opacity: .85; }
.sm-badge {
font-size: .65rem; padding: .05rem .35rem; border-radius: 8px;
background: var(--badge-bg, rgba(122,162,247,.15));
color: var(--badge-fg, #7aa2f7); opacity: .8;
}
.sm-date { font-size: .75rem; opacity: .45; margin-left: .2rem; }
.sm-tags { display: inline-flex; gap: .25rem; margin-left: .2rem; }
.sm-tag {
font-size: .65rem; padding: .05rem .3rem; border-radius: 8px;
background: var(--tag-bg, rgba(160,200,120,.15));
color: var(--tag-fg, #9ece6a); opacity: .85;
}
.sm-children { overflow: hidden; }
.sm-children.collapsed { display: none; }
.sm-label mark {
background: var(--mark-bg, rgba(255,200,50,.3));
color: inherit; border-radius: 2px; padding: 0 1px;
}
.sm-count { font-size: .78rem; opacity: .45; margin-top: .8rem; }
`;
document.head.appendChild(style);
}
// ── Widget scaffold ───────────────────────────────────────────────────
const wrapper = document.createElement("div");
wrapper.className = "sitemap-interactive";
wrapper.innerHTML = `
<div class="sm-toolbar">
<input class="sm-search" type="search" placeholder="Filter pages…" aria-label="Filter" />
<button class="sm-expand-all">⊞ Expand all</button>
<button class="sm-collapse-all">⊟ Collapse all</button>
</div>
<div class="sm-tree" role="tree"></div>
<p class="sm-count"></p>
`;
const treeEl = wrapper.querySelector(".sm-tree");
const searchEl = wrapper.querySelector(".sm-search");
const countEl = wrapper.querySelector(".sm-count");
// ── Tree builder ──────────────────────────────────────────────────────
function countLeaves(nodes) {
let n = 0;
for (const node of nodes) {
if (!node.children || !node.children.length) n++;
else n += countLeaves(node.children);
}
return n;
}
function buildTree(nodes) {
const ul = document.createElement("ul");
for (const node of nodes) {
const li = document.createElement("li");
const row = document.createElement("div");
row.className = "sm-node";
const hasChildren = node.children && node.children.length > 0;
let childrenEl;
if (hasChildren) {
const toggle = document.createElement("button");
toggle.className = "sm-toggle open";
toggle.innerHTML = "▶";
toggle.setAttribute("aria-expanded", "true");
childrenEl = document.createElement("div");
childrenEl.className = "sm-children";
childrenEl.appendChild(buildTree(node.children));
toggle.addEventListener("click", () => {
childrenEl.classList.toggle("collapsed");
toggle.classList.toggle("open");
toggle.setAttribute("aria-expanded",
String(!childrenEl.classList.contains("collapsed")));
});
row.appendChild(toggle);
} else {
const ph = document.createElement("span");
ph.className = "sm-toggle-placeholder";
row.appendChild(ph);
}
const icon = document.createElement("span");
icon.className = "sm-icon";
icon.textContent = hasChildren ? "📂" : "📄";
row.appendChild(icon);
const labelEl = document.createElement("span");
labelEl.className = "sm-label";
if (node.href) {
const a = document.createElement("a");
a.href = node.href;
a.textContent = node.label;
labelEl.appendChild(a);
} else {
const s = document.createElement("span");
s.textContent = node.label;
labelEl.appendChild(s);
}
row.appendChild(labelEl);
if (hasChildren) {
const badge = document.createElement("span");
badge.className = "sm-badge";
badge.textContent = countLeaves(node.children);
row.appendChild(badge);
}
if (node.date) {
const dateSpan = document.createElement("span");
dateSpan.className = "sm-date";
dateSpan.textContent = node.date;
row.appendChild(dateSpan);
}
if (node.tags && node.tags.length) {
const tagsEl = document.createElement("span");
tagsEl.className = "sm-tags";
node.tags.forEach((t) => {
const tag = document.createElement("span");
tag.className = "sm-tag";
tag.textContent = t;
tagsEl.appendChild(tag);
});
row.appendChild(tagsEl);
}
li.appendChild(row);
if (hasChildren) li.appendChild(childrenEl);
ul.appendChild(li);
}
return ul;
}
treeEl.appendChild(buildTree(tree));
// ── Search / filter ───────────────────────────────────────────────────
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function filterTree(q) {
const query = q.trim().toLowerCase();
const re = query ? new RegExp(escapeRe(query), "gi") : null;
let visible = 0;
function walk(ul) {
let anyVisible = false;
for (const li of ul.children) {
const row = li.querySelector(":scope > .sm-node");
const childrenDiv = li.querySelector(":scope > .sm-children");
const labelEl = row.querySelector(".sm-label");
const target = labelEl.querySelector("a") || labelEl.querySelector("span");
const text = target.dataset.orig || target.textContent;
target.dataset.orig = text;
let selfMatch = false;
if (!query) {
target.innerHTML = "";
target.textContent = text;
selfMatch = true;
} else if (text.toLowerCase().includes(query)) {
target.innerHTML = text.replace(re, (m) => `<mark>${m}</mark>`);
selfMatch = true;
} else {
target.innerHTML = "";
target.textContent = text;
}
let childVisible = false;
if (childrenDiv) {
childVisible = walk(childrenDiv.querySelector("ul"));
if (query) {
childrenDiv.classList.toggle("collapsed", !childVisible && !selfMatch);
const toggle = row.querySelector(".sm-toggle");
if (toggle) toggle.classList.toggle("open", childVisible || selfMatch);
}
}
const show = !query || selfMatch || childVisible;
row.classList.toggle("sm-hidden", !show);
if (show) {
anyVisible = true;
if (!childrenDiv) visible++;
}
}
return anyVisible;
}
walk(treeEl.querySelector("ul"));
countEl.textContent = query
? `${visible} page${visible !== 1 ? "s" : ""} matching "${query}"`
: "";
}
searchEl.addEventListener("input", (e) => filterTree(e.target.value));
wrapper.querySelector(".sm-expand-all").addEventListener("click", () => {
treeEl.querySelectorAll(".sm-children").forEach((el) => el.classList.remove("collapsed"));
treeEl.querySelectorAll(".sm-toggle").forEach((el) => {
el.classList.add("open");
el.setAttribute("aria-expanded", "true");
});
});
wrapper.querySelector(".sm-collapse-all").addEventListener("click", () => {
treeEl.querySelectorAll(".sm-children").forEach((el) => el.classList.add("collapsed"));
treeEl.querySelectorAll(".sm-toggle").forEach((el) => {
el.classList.remove("open");
el.setAttribute("aria-expanded", "false");
});
});
// ── Inject into page ──────────────────────────────────────────────────
if (parsed.wrapOutline) {
// Hide original outline divs (direct children of content only)
contentDiv.querySelectorAll(":scope > .outline-2, :scope > .outline-3")
.forEach((el) => (el.style.display = "none"));
// Insert after title + optional intro <p>
const insertAfter = contentDiv.querySelector(":scope > p") || titleEl;
insertAfter.insertAdjacentElement("afterend", wrapper);
} else {
parsed.replaceTarget.replaceWith(wrapper);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

File diff suppressed because it is too large Load Diff

871
assets/scripts/play.js Normal file → Executable file
View File

@@ -1,866 +1,7 @@
(function () { (function () {
const $ = (sel, root = document) => root.querySelector(sel); "use strict";
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); var script = document.createElement("script");
const shuffle = (items) => items.map((value) => ({ value, sort: Math.random() })).sort((a, b) => a.sort - b.sort).map((item) => item.value); script.src = "/assets/scripts/pages/play.js";
const quips = [ script.defer = true;
"A margin note blinks, then pretends it did not.", document.head.appendChild(script);
"Somewhere, a bookmark changes its mind.", }());
"The archive is pleased by unnecessary curiosity.",
"A tiny bell rings in a room you have not built yet.",
"The page remembers that you came here."
];
document.addEventListener("DOMContentLoaded", () => {
const root = $(".play-root[data-play-page]");
if (!root) return;
const page = root.dataset.playPage;
const inits = { hub, memory, constellation, poem, bookshelf, recipe, timeline, ink, terminal, study, sigil, rpg };
if (inits[page]) inits[page](root);
});
function hub(root) {
const links = $$(".play-grid a", root);
const nodes = $$(".play-orbit__node", root);
const title = $("#play-hub-title", root);
const kind = $("#play-hub-kind", root);
const desc = $("#play-hub-desc", root);
const cta = $("#play-hub-link", root);
const whisper = document.createElement("p");
let active = 0;
whisper.className = "play-whisper";
whisper.setAttribute("aria-live", "polite");
$(".play-console__screen", root).appendChild(whisper);
nodes.forEach((node, index) => {
const angle = (index / nodes.length) * Math.PI * 2 - Math.PI / 2;
node.style.left = `${50 + Math.cos(angle) * 43}%`;
node.style.top = `${50 + Math.sin(angle) * 43}%`;
node.addEventListener("click", () => setActive(index));
});
function setActive(index) {
active = (index + links.length) % links.length;
const link = links[active];
links.forEach((item, i) => item.classList.toggle("is-active", i === active));
nodes.forEach((item, i) => item.classList.toggle("is-active", i === active));
title.textContent = link.querySelector("strong").textContent;
kind.textContent = link.dataset.kind;
desc.textContent = link.dataset.desc;
cta.href = link.href;
whisper.textContent = quips[active % quips.length];
}
links.forEach((link, index) => {
link.addEventListener("mouseenter", () => setActive(index));
link.addEventListener("focus", () => setActive(index));
});
$("[data-play-prev]", root).addEventListener("click", () => setActive(active - 1));
$("[data-play-next]", root).addEventListener("click", () => setActive(active + 1));
$("[data-play-random]", root).addEventListener("click", () => setActive(Math.floor(Math.random() * links.length)));
$(".play-orbit", root).addEventListener("dblclick", () => {
whisper.textContent = "You knocked twice. The cabinet knocked once back.";
root.classList.toggle("is-odd");
});
root.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft") setActive(active - 1);
if (event.key === "ArrowRight") setActive(active + 1);
});
setActive(0);
}
function memory(root) {
const symbols = ["Ink", "Lamp", "Tea", "Book", "Map", "Key"];
const board = $("[data-memory-board]", root);
const turnsEl = $("[data-memory-turns]", root);
const matchesEl = $("[data-memory-matches]", root);
const messageEl = $("[data-memory-message]", root);
let open = [];
let turns = 0;
let matches = 0;
let deck = [];
function render() {
board.innerHTML = "";
open = [];
turns = 0;
matches = 0;
deck = shuffle([...symbols, ...symbols]);
turnsEl.textContent = "0";
matchesEl.textContent = "0";
messageEl.textContent = "Cards are shuffled.";
deck.forEach((symbol, index) => {
const card = document.createElement("button");
card.type = "button";
card.className = "memory-card";
card.dataset.symbol = symbol;
card.textContent = "?";
card.style.setProperty("--tilt", `${(index % 5) - 2}deg`);
card.setAttribute("aria-label", "Hidden card");
card.addEventListener("click", () => flip(card));
board.appendChild(card);
});
}
function flip(card) {
if (card.classList.contains("is-open") || card.classList.contains("is-matched") || open.length === 2) return;
card.classList.add("is-open");
card.textContent = card.dataset.symbol;
card.setAttribute("aria-label", card.dataset.symbol);
open.push(card);
if (open.length !== 2) return;
turns += 1;
turnsEl.textContent = String(turns);
const [a, b] = open;
if (a.dataset.symbol === b.dataset.symbol) {
a.classList.add("is-matched");
b.classList.add("is-matched");
open = [];
matches += 1;
matchesEl.textContent = String(matches);
messageEl.textContent = matches === symbols.length ? `Cabinet solved in ${turns} turns.` : "A drawer clicks open.";
} else {
messageEl.textContent = "No match. The cabinet quietly re-files them.";
setTimeout(() => {
a.classList.remove("is-open");
b.classList.remove("is-open");
a.textContent = "?";
b.textContent = "?";
a.setAttribute("aria-label", "Hidden card");
b.setAttribute("aria-label", "Hidden card");
open = [];
}, 650);
}
}
$("[data-memory-reset]", root).addEventListener("click", render);
$("[data-memory-peek]", root).addEventListener("click", () => {
const hidden = $$(".memory-card:not(.is-open):not(.is-matched)", root);
hidden.forEach((card) => {
card.textContent = card.dataset.symbol;
card.classList.add("is-peeking");
});
messageEl.textContent = "A very brief lapse in scholarly discipline.";
setTimeout(() => {
hidden.forEach((card) => {
if (!card.classList.contains("is-open") && !card.classList.contains("is-matched")) card.textContent = "?";
card.classList.remove("is-peeking");
});
}, 900);
});
render();
}
function constellation(root) {
const canvas = $("[data-star-canvas]", root);
const ctx = canvas.getContext("2d");
const linesEl = $("[data-star-lines]", root);
const nameEl = document.createElement("strong");
const stars = Array.from({ length: 18 }, () => ({ x: 50 + Math.random() * 800, y: 45 + Math.random() * 430 }));
const lines = [];
let selected = null;
nameEl.className = "play-live-note";
linesEl.closest(".play-scorebar").appendChild(nameEl);
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#18130f";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "rgba(219, 184, 104, 0.65)";
ctx.lineWidth = 2;
lines.forEach(([a, b]) => {
ctx.beginPath();
ctx.moveTo(stars[a].x, stars[a].y);
ctx.lineTo(stars[b].x, stars[b].y);
ctx.stroke();
});
stars.forEach((star, index) => {
ctx.beginPath();
ctx.fillStyle = index === selected ? "#f6d889" : "#fff8e8";
ctx.arc(star.x, star.y, index === selected ? 7 : 4, 0, Math.PI * 2);
ctx.fill();
});
linesEl.textContent = String(lines.length);
if (lines.length >= 3) nameEl.textContent = `Named: ${pick(["The Patient Spoon", "The South Window", "The Tired Comet", "The Fifth Errand"])}`;
}
canvas.addEventListener("click", (event) => {
const rect = canvas.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * canvas.width;
const y = ((event.clientY - rect.top) / rect.height) * canvas.height;
const hit = stars.findIndex((star) => Math.hypot(star.x - x, star.y - y) < 22);
if (hit < 0) return;
if (selected === null) selected = hit;
else if (selected !== hit) {
lines.push([selected, hit]);
selected = hit;
}
draw();
});
$("[data-star-reset]", root).addEventListener("click", () => {
lines.length = 0;
selected = null;
nameEl.textContent = "";
draw();
});
draw();
}
function poem(root) {
const output = $("[data-poem-output]", root);
const a = ["In the margin", "By the kettle", "Under the desk lamp", "Between two errands", "After the house quiets"];
const b = ["a careful thought", "an old joke", "a stubborn question", "a useful mistake", "a bright scrap"];
const c = ["learns to wait.", "asks for a second reading.", "finds its proper shelf.", "becomes tomorrow's note.", "keeps the page warm."];
let count = 0;
function make() {
count += 1;
output.innerHTML = `${pick(a)}<br>${pick(b)}<br>${pick(c)}`;
output.dataset.stamp = count % 5 === 0 ? "approved by the margin" : "";
}
$("[data-poem-generate]", root).addEventListener("click", make);
make();
}
function bookshelf(root) {
const titles = ["Algebra at Breakfast", "Cabinet of Weather", "Domestic Orbits", "Evening Margins", "Household Engines", "Zettels and Tea"];
const shelf = $("[data-bookshelf]", root);
const status = $("[data-bookshelf-status]", root);
let sortedOnce = false;
setupSortable(shelf, render, check);
$("[data-bookshelf-shuffle]", root).addEventListener("click", () => render(shuffle(titles)));
render(shuffle(titles));
function render(items) {
shelf.innerHTML = "";
items.forEach((title) => {
const book = document.createElement("div");
book.className = "book-spine";
book.draggable = true;
book.dataset.value = title;
book.textContent = title;
shelf.appendChild(book);
});
check();
}
function check() {
const current = $$(".book-spine", shelf).map((book) => book.dataset.value);
const sorted = current.join("|") === titles.join("|");
status.textContent = sorted ? "Shelf sorted. A secret pamphlet slides out." : "Unsorted";
if (sorted && !sortedOnce) {
sortedOnce = true;
const pamphlet = document.createElement("div");
pamphlet.className = "booklet";
pamphlet.textContent = "Pamphlet: On the Correct Order of Small Things";
shelf.appendChild(pamphlet);
}
}
}
function recipe(root) {
const wheel = $("[data-recipe-spin]", root);
const result = $("[data-recipe-result]", root);
const groups = [
["Rice", "Flatbread", "Roast potatoes", "Noodles"],
["Lemon chicken", "Spiced lentils", "Tomato eggs", "Pepper stew"],
["Cucumber salad", "Mint yoghurt", "Pickled onions", "Charred greens"],
["Serve with stories", "Eat outside", "Use the blue plates", "Make extra tea"]
];
let spin = 0;
let feast = 0;
wheel.addEventListener("click", () => {
spin += 540 + Math.floor(Math.random() * 540);
feast += 1;
wheel.style.transform = `rotate(${spin}deg)`;
result.innerHTML = groups.map((group) => `<li>${pick(group)}</li>`).join("") + (feast % 4 === 0 ? "<li>Bonus: someone gets the last crispy bit.</li>" : "");
});
wheel.click();
}
function timeline(root) {
const items = [
{ year: "2022", text: "The personal web habit begins." },
{ year: "2025", text: "Org publishing becomes the main site engine." },
{ year: "2025", text: "Weekly reviews and career notes grow into a library." },
{ year: "2026", text: "Dashboards, services, and authoring tools join the site." },
{ year: "2026", text: "The play wing opens." }
];
const list = $("[data-timeline-list]", root);
const status = $("[data-timeline-status]", root);
const paradox = document.createElement("button");
paradox.type = "button";
paradox.textContent = "Paradox";
$("[data-timeline-shuffle]", root).insertAdjacentElement("afterend", paradox);
setupSortable(list, render, check);
$("[data-timeline-shuffle]", root).addEventListener("click", () => render(shuffle(items)));
paradox.addEventListener("click", () => render([...items].reverse()));
render(shuffle(items));
function render(source) {
list.innerHTML = "";
source.forEach((item, index) => {
const card = document.createElement("div");
card.className = "timeline-card";
card.draggable = true;
card.dataset.value = String(items.indexOf(item));
card.innerHTML = `<span>${item.year}</span><strong>${item.text}</strong>`;
list.appendChild(card);
});
check();
}
function check() {
const current = $$(".timeline-card", list).map((card) => Number(card.dataset.value));
status.textContent = current.every((value, index) => value === index) ? "Timeline restored" : "Arrange the cards";
}
}
function ink(root) {
const canvas = $("[data-ink-canvas]", root);
const ctx = canvas.getContext("2d");
const drops = [];
let moon = false;
function addDrop(x, y, heavy) {
drops.push({ x, y, r: heavy ? 18 : 8, life: heavy ? 1.8 : 1, hue: 28 + Math.random() * 35 });
}
function pointer(event, heavy) {
const rect = canvas.getBoundingClientRect();
addDrop(((event.clientX - rect.left) / rect.width) * canvas.width, ((event.clientY - rect.top) / rect.height) * canvas.height, heavy);
}
canvas.addEventListener("pointermove", (event) => pointer(event, false));
canvas.addEventListener("click", (event) => pointer(event, true));
canvas.addEventListener("dblclick", () => {
moon = !moon;
});
function frame() {
ctx.fillStyle = "rgba(24, 19, 15, 0.16)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (moon) {
ctx.beginPath();
ctx.fillStyle = "rgba(246, 216, 137, 0.72)";
ctx.arc(760, 88, 34, 0, Math.PI * 2);
ctx.fill();
}
drops.forEach((drop) => {
drop.r += 0.7;
drop.life -= 0.012;
ctx.beginPath();
ctx.strokeStyle = `hsla(${drop.hue}, 65%, 68%, ${Math.max(drop.life, 0)})`;
ctx.lineWidth = 2;
ctx.arc(drop.x, drop.y, drop.r, 0, Math.PI * 2);
ctx.stroke();
});
for (let i = drops.length - 1; i >= 0; i -= 1) if (drops[i].life <= 0) drops.splice(i, 1);
requestAnimationFrame(frame);
}
frame();
}
function terminal(root) {
const log = $("[data-terminal-log]", root);
const form = $("[data-terminal-form]", root);
const input = $("[data-terminal-input]", root);
let kindness = 0;
const responses = {
help: "Commands: help, look, map, open drawer, read note, brew tea, hum, knock, save, inventory, clear",
look: "A narrow archive room. A lamp hums. A drawer is labelled MAYBE IMPORTANT.",
map: "You are between the reading desk, the family shelf, and the door back to Play.",
"open drawer": "Inside: a brass key, a receipt, and a note folded twice.",
"read note": "The note says: keep the site useful, but leave a few doors ajar.",
"brew tea": "The room smells briefly of cardamom. Nothing else changes, which is enough.",
hum: "You hum four careful notes. Something behind the wall hums five back.",
knock: "Knock. Knock. ... A polite pause. Knock.",
save: "You save your place in the archive. The archive saves its place in you.",
inventory: "You are carrying: a brass key, a warm cup, and one unreasonable hope."
};
function write(text) {
log.textContent += `${text}\n`;
log.scrollTop = log.scrollHeight;
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const cmd = input.value.trim().toLowerCase();
if (!cmd) return;
input.value = "";
if (cmd === "clear") {
log.textContent = "";
return;
}
write(`> ${cmd}`);
if (cmd === "pet book") {
kindness += 1;
write(kindness > 2 ? "The book follows you for exactly three pages." : "The book accepts this with suspicious dignity.");
} else {
write(responses[cmd] || "The archive declines to understand that command.");
}
});
write("Archive terminal ready. Type help. The cursor is listening.");
input.focus();
}
function study(root) {
const time = $("[data-study-time]", root);
const scene = $("[data-study-scene]", root);
let total = 300;
let left = total;
let timer = null;
const note = document.createElement("div");
note.className = "play-live-note";
note.textContent = "The lamp is cold.";
time.insertAdjacentElement("afterend", note);
function draw() {
const done = 1 - left / total;
scene.style.setProperty("--lamp", String(0.18 + done * 0.72));
scene.style.setProperty("--glow", `${done * 70}px`);
const m = String(Math.floor(left / 60)).padStart(2, "0");
const s = String(left % 60).padStart(2, "0");
time.textContent = `${m}:${s}`;
if (left === 0) note.textContent = "Focus complete. The desk looks proud.";
else if (done > 0.66) note.textContent = "The page has warmed through.";
else if (done > 0.33) note.textContent = "The lamp has settled into its work.";
}
function start() {
if (timer) return;
timer = setInterval(() => {
left = Math.max(0, left - 1);
draw();
if (left === 0) pause();
}, 1000);
}
function pause() {
clearInterval(timer);
timer = null;
}
$("[data-study-start]", root).addEventListener("click", start);
$("[data-study-pause]", root).addEventListener("click", pause);
$("[data-study-reset]", root).addEventListener("click", () => {
pause();
left = total;
draw();
});
draw();
}
function sigil(root) {
const canvas = $("[data-sigil-canvas]", root);
const ctx = canvas.getContext("2d");
const initials = $("[data-sigil-initials]", root);
const motto = $("[data-sigil-motto]", root);
let wax = "#9c6b2f";
const waxButton = document.createElement("button");
waxButton.type = "button";
waxButton.textContent = "New wax";
$("[data-sigil-form]", root).appendChild(waxButton);
function draw() {
const text = (initials.value || "ZXH").toUpperCase();
const phrase = motto.value || "Learn, make, remember";
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#fbf7ec";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.translate(260, 260);
ctx.strokeStyle = wax;
ctx.lineWidth = 10;
ctx.beginPath();
ctx.arc(0, 0, 190, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 2;
for (let i = 0; i < 12; i += 1) {
ctx.rotate(Math.PI / 6);
ctx.beginPath();
ctx.moveTo(0, -150);
ctx.lineTo(0, -185);
ctx.stroke();
}
ctx.rotate(-Math.PI * 2);
ctx.fillStyle = "#2a2118";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "900 94px Georgia, serif";
ctx.fillText(text.slice(0, 4), 0, -10);
ctx.font = "700 22px Georgia, serif";
ctx.fillText(phrase.slice(0, 34), 0, 88);
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
waxButton.addEventListener("click", () => {
wax = pick(["#9c6b2f", "#8a3f3f", "#486b57", "#3e5f8a", "#5f4b8b"]);
draw();
});
$("[data-sigil-form]", root).addEventListener("submit", (event) => {
event.preventDefault();
draw();
});
initials.addEventListener("input", draw);
motto.addEventListener("input", draw);
draw();
}
function rpg(root) {
if (window.AshBelowLakeRpg) return window.AshBelowLakeRpg(root);
const canvas = $("[data-rpg-canvas]", root);
const ctx = canvas.getContext("2d");
const els = {
speaker: $("[data-rpg-speaker]", root),
line: $("[data-rpg-line]", root),
room: $("[data-rpg-room]", root),
route: $("[data-rpg-route]", root),
hearts: $("[data-rpg-hearts]", root),
quests: $("[data-rpg-quests]", root),
inventory: $("[data-rpg-inventory]", root),
status: $("[data-rpg-status]", root)
};
const tile = 32;
const saveSlot = "hollow-archive";
const fallbackKey = "play:rpg:hollow-archive";
const items = {
lamp: { label: "Desk Lamp", room: "entrance", x: 10, y: 8, color: "#f2c94c" },
page: { label: "Loose Page", room: "stacks", x: 18, y: 5, color: "#f7efe0" },
key: { label: "Basement Key", room: "garden", x: 4, y: 11, color: "#d59b45" }
};
const npcs = {
archivist: { name: "Archivist", room: "entrance", x: 6, y: 6, color: "#b98bff" },
shade: { name: "Shy Shade", room: "stacks", x: 17, y: 9, color: "#6ed0d4" },
gate: { name: "Iron Door", room: "garden", x: 19, y: 10, color: "#8a8f98" }
};
const rooms = {
entrance: {
name: "Entrance",
floor: "#353029",
exits: [{ x: 22, y: 7, to: "stacks", px: 1, py: 7 }],
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 16], [23, 0, 1, 6], [23, 9, 1, 7], [8, 3, 1, 8], [14, 7, 5, 1]])
},
stacks: {
name: "Stacks",
floor: "#242d35",
exits: [{ x: 0, y: 7, to: "entrance", px: 22, py: 7 }, { x: 23, y: 12, to: "garden", px: 1, py: 12 }],
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 6], [0, 9, 1, 7], [23, 0, 1, 11], [23, 14, 1, 2], [4, 3, 2, 10], [10, 2, 2, 11], [16, 3, 2, 5]])
},
garden: {
name: "Moon Garden",
floor: "#21362e",
exits: [{ x: 0, y: 12, to: "stacks", px: 22, py: 12 }],
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 11], [0, 14, 1, 2], [23, 0, 1, 16], [7, 4, 10, 1], [7, 10, 1, 4], [13, 10, 1, 4]])
}
};
let state = freshState();
let running = false;
function freshState() {
return {
room: "entrance",
player: { x: 3, y: 7, facing: "down" },
inventory: [],
flags: {},
route: "Undecided",
hearts: 3,
ending: null,
message: { speaker: "Archivist", line: "The archive waits. Find the lamp, help the shade, then decide what to do with the locked door." }
};
}
function rects(sources) {
const set = new Set();
sources.forEach(([x, y, w, h]) => {
for (let yy = y; yy < y + h; yy += 1) for (let xx = x; xx < x + w; xx += 1) set.add(`${xx},${yy}`);
});
return set;
}
function draw() {
const room = rooms[state.room];
ctx.fillStyle = "#14110e";
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < 16; y += 1) {
for (let x = 0; x < 24; x += 1) {
ctx.fillStyle = room.walls.has(`${x},${y}`) ? "#181716" : room.floor;
ctx.fillRect(x * tile, y * tile, tile, tile);
ctx.strokeStyle = "rgba(255,255,255,0.035)";
ctx.strokeRect(x * tile, y * tile, tile, tile);
}
}
room.exits.forEach((exit) => drawGlyph(exit.x, exit.y, "#c48a41", "door"));
Object.entries(items).forEach(([id, item]) => {
if (item.room === state.room && !state.inventory.includes(id)) drawGlyph(item.x, item.y, item.color, "item");
});
Object.values(npcs).forEach((npc) => {
if (npc.room === state.room) drawGlyph(npc.x, npc.y, npc.color, npc.name === "Iron Door" ? "doorNpc" : "npc");
});
drawPlayer();
renderHud();
requestAnimationFrame(draw);
}
function drawGlyph(x, y, color, kind) {
const px = x * tile;
const py = y * tile;
ctx.fillStyle = color;
if (kind === "item") {
ctx.fillRect(px + 10, py + 10, 12, 12);
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.fillRect(px + 13, py + 7, 6, 6);
} else if (kind === "door" || kind === "doorNpc") {
ctx.fillRect(px + 7, py + 4, 18, 25);
ctx.fillStyle = "#211812";
ctx.fillRect(px + 20, py + 16, 3, 3);
} else {
ctx.beginPath();
ctx.arc(px + 16, py + 12, 9, 0, Math.PI * 2);
ctx.fill();
ctx.fillRect(px + 8, py + 20, 16, 8);
}
}
function drawPlayer() {
const px = state.player.x * tile;
const py = state.player.y * tile;
ctx.fillStyle = "#f35f5f";
ctx.beginPath();
ctx.moveTo(px + 16, py + 7);
ctx.bezierCurveTo(px + 2, py + 2, px + 1, py + 22, px + 16, py + 28);
ctx.bezierCurveTo(px + 31, py + 22, px + 30, py + 2, px + 16, py + 7);
ctx.fill();
ctx.fillStyle = "#fff8e8";
ctx.fillRect(px + 11, py + 13, 4, 4);
ctx.fillRect(px + 18, py + 13, 4, 4);
}
function renderHud() {
els.speaker.textContent = state.message.speaker;
els.line.textContent = state.ending ? endingLine() : state.message.line;
els.room.textContent = rooms[state.room].name;
els.route.textContent = state.route;
els.hearts.textContent = String(state.hearts);
els.inventory.innerHTML = state.inventory.length
? state.inventory.map((id) => `<li>${items[id].label}</li>`).join("")
: "<li>Empty</li>";
const questRows = [
["Find a light", state.inventory.includes("lamp")],
["Return the loose page to the shade", state.flags.shadeHelped],
["Open, force, or leave the iron door", Boolean(state.ending)]
];
els.quests.innerHTML = questRows.map(([text, done]) => `<li class="${done ? "is-done" : ""}">${done ? "Done: " : ""}${text}</li>`).join("");
}
function move(dx, dy, facing) {
if (!running || state.ending) return;
state.player.facing = facing;
const nx = state.player.x + dx;
const ny = state.player.y + dy;
const room = rooms[state.room];
const exit = room.exits.find((candidate) => candidate.x === nx && candidate.y === ny);
if (exit) {
state.room = exit.to;
state.player.x = exit.px;
state.player.y = exit.py;
say("Narrator", `You enter ${rooms[state.room].name}.`);
autosave();
return;
}
if (nx < 0 || ny < 0 || nx > 23 || ny > 15 || room.walls.has(`${nx},${ny}`) || npcAt(nx, ny)) return;
state.player.x = nx;
state.player.y = ny;
const item = itemAt(nx, ny);
if (item) take(item);
}
function act() {
if (!running) return start();
if (state.ending) return;
const front = inFront();
const npc = npcAt(front.x, front.y);
if (npc) talk(npc);
else {
const here = itemAt(state.player.x, state.player.y);
if (here) take(here);
else say("Narrator", "Dust moves in the light. Nothing asks to be changed here.");
}
autosave();
}
function inFront() {
const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[state.player.facing] || [0, 1];
return { x: state.player.x + delta[0], y: state.player.y + delta[1] };
}
function itemAt(x, y) {
return Object.keys(items).find((id) => {
const item = items[id];
return item.room === state.room && item.x === x && item.y === y && !state.inventory.includes(id);
});
}
function npcAt(x, y) {
return Object.keys(npcs).find((id) => {
const npc = npcs[id];
return npc.room === state.room && npc.x === x && npc.y === y;
});
}
function take(id) {
state.inventory.push(id);
if (id === "lamp") state.route = "Gentle";
if (id === "key" && !state.flags.shadeHelped) state.route = "Power";
say("Found", `${items[id].label} joined your inventory.`);
}
function talk(id) {
if (id === "archivist") {
if (!state.inventory.includes("lamp")) say("Archivist", "Take the lamp from the lower desk. The stacks dislike being crossed in the dark.");
else if (!state.flags.shadeHelped) say("Archivist", "A loose page has gone missing. The quiet reader in the stacks knows where it belongs.");
else say("Archivist", "You have been kind to a forgotten page. The garden door will remember that.");
}
if (id === "shade") {
if (!state.inventory.includes("page")) say("Shy Shade", "I lost the page with my name on it. It fell somewhere nearby.");
else {
state.flags.shadeHelped = true;
state.inventory = state.inventory.filter((item) => item !== "page");
state.route = "Mercy";
say("Shy Shade", "You returned my page instead of keeping it. Take the honest route through the garden.");
}
}
if (id === "gate") {
if (state.flags.shadeHelped) end("mercy");
else if (state.inventory.includes("key")) end("power");
else end("quiet");
}
}
function end(kind) {
state.ending = kind;
state.route = kind === "mercy" ? "Mercy" : kind === "power" ? "Power" : "Quiet";
say("Ending", endingLine());
save();
}
function endingLine() {
if (state.ending === "mercy") return "Mercy ending: the iron door opens without a sound, and every returned page remembers your name.";
if (state.ending === "power") return "Power ending: the key turns, but the archive grows colder around the missing page.";
if (state.ending === "quiet") return "Quiet ending: you leave the locked door alone. Some mysteries stay intact.";
return state.message.line;
}
function say(speaker, line) {
state.message = { speaker, line };
}
function start() {
running = true;
say("Archivist", "Walk the archive. Speak gently, or take what you need. The route will notice.");
els.status.textContent = "Started. Progress autosaves after room changes and actions.";
}
async function save() {
localStorage.setItem(fallbackKey, JSON.stringify(state));
try {
const response = await fetch(`/api/play/rpg/save/${saveSlot}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payload: state })
});
if (!response.ok) throw new Error(`Save failed: ${response.status}`);
els.status.textContent = "Saved to backend.";
} catch (_error) {
els.status.textContent = "Saved locally. Backend save API was not reachable.";
}
}
async function load() {
try {
const response = await fetch(`/api/play/rpg/save/${saveSlot}`);
if (response.ok) {
const data = await response.json();
state = normalizeState(data.payload);
running = true;
els.status.textContent = "Loaded from backend.";
return;
}
} catch (_error) {
/* Fall through to local save. */
}
const local = localStorage.getItem(fallbackKey);
if (local) {
state = normalizeState(JSON.parse(local));
running = true;
els.status.textContent = "Loaded local save.";
} else {
els.status.textContent = "No save found.";
}
}
function normalizeState(candidate) {
return Object.assign(freshState(), candidate || {}, {
player: Object.assign(freshState().player, (candidate && candidate.player) || {}),
inventory: Array.isArray(candidate && candidate.inventory) ? candidate.inventory.filter((id) => items[id]) : [],
flags: Object.assign({}, (candidate && candidate.flags) || {}),
message: Object.assign(freshState().message, (candidate && candidate.message) || {})
});
}
function autosave() {
save();
}
$("[data-rpg-start]", root).addEventListener("click", start);
$("[data-rpg-save]", root).addEventListener("click", save);
$("[data-rpg-load]", root).addEventListener("click", load);
$("[data-rpg-reset]", root).addEventListener("click", async () => {
state = freshState();
running = false;
localStorage.removeItem(fallbackKey);
try {
await fetch(`/api/play/rpg/save/${saveSlot}`, { method: "DELETE" });
els.status.textContent = "Reset and cleared backend save.";
} catch (_error) {
els.status.textContent = "Reset locally. Backend save API was not reachable.";
}
});
$("[data-rpg-act]", root).addEventListener("click", act);
$$("[data-rpg-move]", root).forEach((button) => {
const moves = { up: [0, -1, "up"], down: [0, 1, "down"], left: [-1, 0, "left"], right: [1, 0, "right"] };
button.addEventListener("click", () => move(...moves[button.dataset.rpgMove]));
});
window.addEventListener("keydown", (event) => {
if (!root.isConnected) return;
const tag = event.target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
const keys = {
ArrowUp: [0, -1, "up"], w: [0, -1, "up"],
ArrowDown: [0, 1, "down"], s: [0, 1, "down"],
ArrowLeft: [-1, 0, "left"], a: [-1, 0, "left"],
ArrowRight: [1, 0, "right"], d: [1, 0, "right"]
};
if (keys[event.key]) {
event.preventDefault();
move(...keys[event.key]);
}
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
act();
}
});
draw();
load();
}
function setupSortable(container, _render, after) {
let dragged = null;
container.addEventListener("dragstart", (event) => {
dragged = event.target.closest("[draggable='true']");
if (dragged) event.dataTransfer.effectAllowed = "move";
});
container.addEventListener("dragover", (event) => {
event.preventDefault();
const target = event.target.closest("[draggable='true']");
if (!dragged || !target || target === dragged) return;
const rect = target.getBoundingClientRect();
const before = event.clientY < rect.top + rect.height / 2 || event.clientX < rect.left + rect.width / 2;
container.insertBefore(dragged, before ? target : target.nextSibling);
after();
});
container.addEventListener("dragend", () => {
dragged = null;
after();
});
}
function pick(items) {
return items[Math.floor(Math.random() * items.length)];
}
})();

View File

@@ -1,341 +1,17 @@
/* Event listener function for the COPY BUTTON */
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll("pre.src").forEach(function (codeBlock) {
const button = document.createElement("button");
button.innerText = "Copy";
button.className = "copy-btn";
// Append button inside <pre>
codeBlock.appendChild(button);
button.addEventListener("click", function () {
const text = codeBlock.innerText.replace(button.innerText, ""); // exclude button text
navigator.clipboard.writeText(text.trim()).then(() => {
button.innerText = "Copied!";
setTimeout(() => (button.innerText = "Copy"), 1500);
});
});
});
});
/* Event listener for footnotes and sidenotes*/
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('a.footref[href^="#fn"]').forEach(ref => {
const sup = ref.closest("sup") || ref;
// idempotent: don't insert twice
if (sup.nextElementSibling && sup.nextElementSibling.classList?.contains("footnote-sidenote")) return;
const targetId = ref.getAttribute("href").replace(/^#/, ""); // works for fn.2 or fn2
const anchor = document.getElementById(targetId);
if (!anchor) return;
const footdef = anchor.closest(".footdef") || anchor.parentElement;
if (!footdef) return;
// 1) Prefer leaf paragraphs to avoid div+p duplication
let paras = footdef.querySelectorAll("p.footpara");
if (!paras.length) {
// fallback: any .footpara elements that don't contain another .footpara
paras = footdef.querySelectorAll(".footpara:not(:has(.footpara))");
}
// 2) Build HTML, de-duplicating by text content
let parts = [];
if (paras.length) {
const seen = new Set();
parts = Array.from(paras).map(p => {
const txt = p.textContent.trim().replace(/\s+/g, " ");
if (seen.has(txt)) return "";
seen.add(txt);
return p.innerHTML.trim();
}).filter(Boolean);
}
// 3) Fallback: clean full block if no paras found
if (!parts.length) {
const clone = footdef.cloneNode(true);
clone.querySelectorAll("sup.footnum, a[role='doc-backlink']").forEach(n => n.remove());
parts = [clone.innerHTML.trim()];
}
// 4) Insert the sidenote
const sn = document.createElement("span");
sn.className = "sidenote footnote-sidenote";
sn.setAttribute("data-fn", (ref.textContent || "").trim());
sn.innerHTML = parts.join(" ");
sup.insertAdjacentElement("afterend", sn);
});
});
/* Function for setting the theme */
(function(){
const root = document.documentElement;
const storageKey = "theme";
const themes = ["light", "dark", "dark-academia"];
const labels = {
"light": "Light",
"dark": "Dark",
"dark-academia": "Academia"
};
const saved = localStorage.getItem(storageKey);
if (themes.includes(saved)) {
root.setAttribute("data-theme", saved);
}
const btn = document.getElementById("theme-toggle");
if (!btn) return;
const updateButton = () => {
const current = root.getAttribute("data-theme");
const label = labels[current] || "Auto";
btn.textContent = `Theme: ${label}`;
btn.setAttribute("aria-label", `Current theme: ${label}. Switch theme.`);
};
updateButton();
btn.addEventListener("click", () => {
const current = root.getAttribute("data-theme");
const index = themes.indexOf(current);
const target = themes[index === -1 ? 1 : (index + 1) % themes.length];
root.setAttribute("data-theme", target);
localStorage.setItem(storageKey, target);
updateButton();
});
})();
// Simple, timezone-safe if you pass UTC (…Z) in the datetime
document.addEventListener('DOMContentLoaded', () => {
const els = document.querySelectorAll('time.countdown');
if (!els.length) return;
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
const render = (el) => {
const raw = el.getAttribute('datetime');
const label = el.dataset.label || '';
const target = new Date(raw); // Prefer ISO like 2025-12-31T00:00:00Z
if (isNaN(target)) { el.textContent = '—'; return; }
const now = new Date();
let diff = target - now;
if (diff <= 0) {
el.textContent = `${label ? label + ' ' : ''}today`;
el.classList.add('expired');
return;
}
const d = Math.floor(diff / 86400000); diff -= d * 86400000;
const h = Math.floor(diff / 3600000); diff -= h * 3600000;
const m = Math.floor(diff / 60000); diff -= m * 60000;
const s = Math.floor(diff / 1000);
const pieces = [];
if (d) pieces.push(plural(d, 'day'));
pieces.push(`${h}h ${m}m ${s}s`);
el.textContent = `${label ? label + ' in : ' : ''}${pieces.join(' ')}`;
};
const tick = () => els.forEach(render);
tick();
setInterval(tick, 1000); // update every second
});
/* Event listener for scrolling and changing the active label on the TOC */
document.addEventListener("DOMContentLoaded", () => {
const toc = document.querySelector("#text-table-of-contents");
if (!toc) { console.warn("No #text-table-of-contents found"); return; }
const links = toc.querySelectorAll('a[href^="#"]'); // '^=' is a starts with operator.
// <a href="#introduction">Intro</a> matches
if (!links.length) { console.warn("No ToC links found"); return; }
// Map: id -> link
const linkById = new Map();
links.forEach(a => {
const id = decodeURIComponent(a.getAttribute("href").slice(1));
const el = document.getElementById(id);
if (el) linkById.set(id, a);
});
if (!linkById.size) { console.warn("No matching headings with IDs"); return; }
// Headings to observe (h2h4 usually)
const headings = [...document.querySelectorAll("h2[id], h3[id], h4[id]")]
.filter(h => linkById.has(h.id));
// Helper to mark active
const setActive = (id) => {
links.forEach(a => {
const active = a.getAttribute("href") === `#${id}`;
a.classList.toggle("is-active", active);
if (active) a.setAttribute("aria-current", "true");
else a.removeAttribute("aria-current");
});
};
// Calculate sticky header offset in px
const headerOffsetPx = 6.5 * parseFloat(getComputedStyle(document.documentElement).fontSize);
// Track visible headings (id -> distance from top)
const visible = new Map();
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const id = entry.target.id;
if (entry.isIntersecting) {
// How far from the top (after header offset)
const dist = entry.target.getBoundingClientRect().top - headerOffsetPx;
visible.set(id, dist);
} else {
visible.delete(id);
}
});
if (visible.size) {
// Choose the heading closest to the top (>= -headerOffset)
const topMost = [...visible.entries()]
.sort((a,b) => Math.abs(a[1]) - Math.abs(b[1]))[0][0];
setActive(topMost);
// console.log("Active:", topMost, visible);
}
}, {
root: null, // track relative to viewport
rootMargin: `-${headerOffsetPx}px 0px -70% 0px`,
threshold: [0, 0.01, 0.1] // fire as soon as it enters
});
headings.forEach(h => observer.observe(h));
// Initial highlight (in case load midpage)
let bestId = null, bestDist = Infinity;
headings.forEach(h => {
const top = h.getBoundingClientRect().top - headerOffsetPx;
const dist = top < 0 ? Math.abs(top) : top + 1e6;
if (dist < bestDist) { bestDist = dist; bestId = h.id; }
});
if (bestId) setActive(bestId);
// smooth-scroll ToC clicks
toc.addEventListener("click", (e) => {
const a = e.target.closest('a[href^="#"]');
if (!a) return;
const id = decodeURIComponent(a.hash.slice(1));
const el = document.getElementById(id);
if (!el) return;
e.preventDefault();
el.scrollIntoView({ behavior: "smooth", block: "start" });
el.setAttribute("tabindex", "-1");
el.focus({ preventScroll: true });
history.pushState(null, "", `#${id}`);
});
});
// Make Mermaid diagrams use the active site theme and zoom controls.
(function () { (function () {
function cssVar(name, fallback) { "use strict";
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function mermaidConfig() { [
return { "/assets/scripts/ui/copy-buttons.js",
startOnLoad: false, "/assets/scripts/ui/footnote-sidenotes.js",
theme: "base", "/assets/scripts/application/theme-switcher.js",
themeVariables: { "/assets/scripts/ui/countdown.js",
background: cssVar("--bg", "#faf9f6"), "/assets/scripts/ui/toc-active-link.js",
mainBkg: cssVar("--surface", "#ffffff"), "/assets/scripts/ui/mermaid-diagrams.js"
primaryColor: cssVar("--surface", "#ffffff"), ].forEach(function (src) {
primaryTextColor: cssVar("--fg", "#000000"), var script = document.createElement("script");
primaryBorderColor: cssVar("--accent", "#2563eb"), script.src = src;
secondaryColor: cssVar("--surface-soft", "#f3f1eb"), script.defer = true;
tertiaryColor: cssVar("--bg", "#faf9f6"), document.head.appendChild(script);
clusterBkg: cssVar("--surface-soft", "#f3f1eb"), });
clusterBorder: cssVar("--border", "#d7d7d7"), }());
lineColor: cssVar("--border", "#d7d7d7"),
textColor: cssVar("--fg", "#000000"),
edgeLabelBackground: cssVar("--surface", "#ffffff"),
fontFamily: cssVar("--font-body", "Noto, system-ui, sans-serif")
},
flowchart: {
htmlLabels: true,
curve: "basis"
}
};
}
function decodeMermaidSource(source) {
const textarea = document.createElement("textarea");
textarea.innerHTML = source;
return textarea.value.trim();
}
async function renderMermaid() {
if (!window.mermaid) return;
const diagrams = document.querySelectorAll(".mermaid");
if (!diagrams.length) return;
diagrams.forEach((el) => {
if (!el.dataset.source) {
el.dataset.source = decodeMermaidSource(el.textContent || el.innerHTML);
}
el.removeAttribute("data-processed");
el.innerHTML = el.dataset.source;
});
mermaid.initialize(mermaidConfig());
await mermaid.run({ querySelector: ".mermaid" });
wrapMermaidDiagrams();
}
function wrapMermaidDiagrams() {
document.querySelectorAll(".mermaid").forEach((el) => {
if (el.closest(".mermaid-container")) return;
const container = document.createElement("div");
container.className = "mermaid-container";
if ((el.dataset.source || "").includes('root(["zxh"])')) {
container.classList.add("mermaid-container--site-map");
}
const controls = document.createElement("div");
controls.className = "mermaid-zoom-controls";
controls.innerHTML = `
<button class="zoom-in" type="button" aria-label="Zoom in">+</button>
<button class="zoom-out" type="button" aria-label="Zoom out">-</button>
`;
el.replaceWith(container);
container.appendChild(controls);
container.appendChild(el);
let scale = 1;
const setScale = (next) => {
const svg = container.querySelector(".mermaid svg");
if (!svg) return;
scale = Math.max(0.2, Math.min(3, next));
svg.style.transform = `scale(${scale})`;
};
controls.querySelector(".zoom-in").addEventListener("click", () => setScale(scale + 0.1));
controls.querySelector(".zoom-out").addEventListener("click", () => setScale(scale - 0.1));
container.addEventListener("wheel", (e) => {
if (!e.ctrlKey) return;
e.preventDefault();
setScale(scale + (e.deltaY < 0 ? 0.05 : -0.05));
});
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", renderMermaid);
} else {
renderMermaid();
}
})();

View File

@@ -66,7 +66,7 @@ function buildIndex() {
async function initSearch() { async function initSearch() {
try { try {
const response = await fetch("/test.json"); const response = await fetch("/search-index.json");
const json = await response.json(); const json = await response.json();
extractDocuments(json); extractDocuments(json);

View File

@@ -1,398 +1,7 @@
/**
* sitemap-interactive.js
* Drop into /assets/scripts/ — no .org changes required.
*
* Handles two page structures generated by org-publish:
* 1. FLAT LIST — a top-level <ul class="org-ul"> (e.g. Sitemap)
* 2. OUTLINE — <div class="outline-2/3"> with <h2>/<h3> headings and
* nested <ul class="org-ul"> (e.g. "2025 List", "Blogs List")
*
* Activated on any page whose <h1 class="title"> matches SITEMAP_PATTERNS.
*/
(function () { (function () {
"use strict"; "use strict";
var script = document.createElement("script");
const SITEMAP_PATTERNS = ["sitemap", "list"]; script.src = "/assets/scripts/pages/sitemap-interactive.js";
script.defer = true;
function init() { document.head.appendChild(script);
const titleEl = document.querySelector("h1.title"); }());
if (!titleEl) return;
const title = titleEl.textContent.trim().toLowerCase();
if (!SITEMAP_PATTERNS.some((p) => title.includes(p))) return;
const contentDiv = document.getElementById("content");
if (!contentDiv) return;
const hasOutline = !!contentDiv.querySelector(".outline-2, .outline-3");
const topUl = !hasOutline && contentDiv.querySelector("ul.org-ul");
if (!hasOutline && !topUl) return;
// ── Parsers ───────────────────────────────────────────────────────────
function parseLi(li) {
const link = li.querySelector(":scope > a");
const childUl = li.querySelector(":scope > ul");
const textNode = Array.from(li.childNodes).find(
(n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim()
);
const tags = Array.from(li.querySelectorAll(".post-tag")).map((t) =>
t.textContent.trim()
);
const dateEl = li.querySelector(".post-date");
const date = dateEl ? dateEl.textContent.trim() : null;
return {
label: link
? link.textContent.trim()
: textNode
? textNode.textContent.trim()
: li.childNodes[0]?.textContent?.trim() || "",
href: link ? link.getAttribute("href") : null,
tags,
date,
children: childUl ? parseUl(childUl) : [],
};
}
function parseUl(ul) {
return Array.from(ul.children)
.filter((li) => li.tagName === "LI")
.map(parseLi);
}
function parseFlatList() {
return { tree: parseUl(topUl), replaceTarget: topUl, wrapOutline: false };
}
function parseOutline() {
const tree = [];
const outline2s = contentDiv.querySelectorAll(":scope .outline-2");
if (outline2s.length === 0) {
// Only outline-3 directly (flat month grouping without year wrapper)
contentDiv.querySelectorAll(":scope .outline-3").forEach((section) => {
const heading = section.querySelector("h3");
const ul = section.querySelector("ul.org-ul");
tree.push({
label: heading ? heading.textContent.trim() : "Section",
href: null, tags: [], date: null,
children: ul ? parseUl(ul) : [],
});
});
} else {
outline2s.forEach((o2) => {
const h2 = o2.querySelector(":scope > div > h2, :scope > h2");
const groupNode = {
label: h2 ? h2.textContent.trim() : "Group",
href: null, tags: [], date: null,
children: [],
};
const outline3s = o2.querySelectorAll(".outline-3");
if (outline3s.length > 0) {
outline3s.forEach((o3) => {
const h3 = o3.querySelector(":scope > div > h3, :scope > h3");
const ul = o3.querySelector("ul.org-ul");
groupNode.children.push({
label: h3 ? h3.textContent.trim() : "Month",
href: null, tags: [], date: null,
children: ul ? parseUl(ul) : [],
});
});
} else {
const ul = o2.querySelector("ul.org-ul");
if (ul) groupNode.children = parseUl(ul);
}
tree.push(groupNode);
});
}
return { tree, replaceTarget: null, wrapOutline: true };
}
const parsed = hasOutline ? parseOutline() : parseFlatList();
const { tree } = parsed;
if (!tree.length) return;
// ── Styles ────────────────────────────────────────────────────────────
if (!document.getElementById("sm-styles")) {
const style = document.createElement("style");
style.id = "sm-styles";
style.textContent = `
.sitemap-interactive { font-family: inherit; margin: 1.5rem 0; }
.sm-toolbar {
display: flex; align-items: center; gap: .6rem;
margin-bottom: 1rem; flex-wrap: wrap;
}
.sm-search {
flex: 1; min-width: 160px; padding: .35rem .7rem;
border: 1px solid var(--border, #444); border-radius: 4px;
background: var(--bg, transparent); color: inherit; font-size: .9rem;
}
.sm-search:focus { outline: 2px solid var(--accent, #7aa2f7); outline-offset: 1px; }
.sm-expand-all, .sm-collapse-all {
padding: .3rem .65rem; border: 1px solid var(--border, #444);
border-radius: 4px; background: transparent; color: inherit;
font-size: .8rem; cursor: pointer; opacity: .75; transition: opacity .15s;
}
.sm-expand-all:hover, .sm-collapse-all:hover { opacity: 1; }
.sm-tree ul { list-style: none; margin: 0; padding: 0 0 0 1.4rem; }
.sm-tree > ul { padding-left: 0; }
.sm-tree li { margin: 0; }
.sm-node {
display: flex; align-items: center; gap: .4rem;
padding: .2rem .3rem; border-radius: 4px;
line-height: 1.5; transition: background .1s; flex-wrap: wrap;
}
.sm-node:hover { background: var(--hover-bg, rgba(122,162,247,.08)); }
.sm-node.sm-hidden { display: none; }
.sm-toggle {
width: 1.2rem; height: 1.2rem; display: inline-flex;
align-items: center; justify-content: center;
cursor: pointer; border: none; background: none; color: inherit;
font-size: .7rem; opacity: .6;
transition: transform .18s, opacity .15s;
flex-shrink: 0; padding: 0; border-radius: 3px;
}
.sm-toggle:hover { opacity: 1; background: var(--hover-bg, rgba(122,162,247,.15)); }
.sm-toggle.open { transform: rotate(90deg); }
.sm-toggle-placeholder { width: 1.2rem; flex-shrink: 0; }
.sm-icon { font-size: .8rem; opacity: .5; flex-shrink: 0; }
.sm-label a { color: var(--link, inherit); text-decoration: none; font-size: .9rem; }
.sm-label a:hover { text-decoration: underline; }
.sm-label span { font-size: .9rem; font-weight: 600; opacity: .85; }
.sm-badge {
font-size: .65rem; padding: .05rem .35rem; border-radius: 8px;
background: var(--badge-bg, rgba(122,162,247,.15));
color: var(--badge-fg, #7aa2f7); opacity: .8;
}
.sm-date { font-size: .75rem; opacity: .45; margin-left: .2rem; }
.sm-tags { display: inline-flex; gap: .25rem; margin-left: .2rem; }
.sm-tag {
font-size: .65rem; padding: .05rem .3rem; border-radius: 8px;
background: var(--tag-bg, rgba(160,200,120,.15));
color: var(--tag-fg, #9ece6a); opacity: .85;
}
.sm-children { overflow: hidden; }
.sm-children.collapsed { display: none; }
.sm-label mark {
background: var(--mark-bg, rgba(255,200,50,.3));
color: inherit; border-radius: 2px; padding: 0 1px;
}
.sm-count { font-size: .78rem; opacity: .45; margin-top: .8rem; }
`;
document.head.appendChild(style);
}
// ── Widget scaffold ───────────────────────────────────────────────────
const wrapper = document.createElement("div");
wrapper.className = "sitemap-interactive";
wrapper.innerHTML = `
<div class="sm-toolbar">
<input class="sm-search" type="search" placeholder="Filter pages…" aria-label="Filter" />
<button class="sm-expand-all">⊞ Expand all</button>
<button class="sm-collapse-all">⊟ Collapse all</button>
</div>
<div class="sm-tree" role="tree"></div>
<p class="sm-count"></p>
`;
const treeEl = wrapper.querySelector(".sm-tree");
const searchEl = wrapper.querySelector(".sm-search");
const countEl = wrapper.querySelector(".sm-count");
// ── Tree builder ──────────────────────────────────────────────────────
function countLeaves(nodes) {
let n = 0;
for (const node of nodes) {
if (!node.children || !node.children.length) n++;
else n += countLeaves(node.children);
}
return n;
}
function buildTree(nodes) {
const ul = document.createElement("ul");
for (const node of nodes) {
const li = document.createElement("li");
const row = document.createElement("div");
row.className = "sm-node";
const hasChildren = node.children && node.children.length > 0;
let childrenEl;
if (hasChildren) {
const toggle = document.createElement("button");
toggle.className = "sm-toggle open";
toggle.innerHTML = "▶";
toggle.setAttribute("aria-expanded", "true");
childrenEl = document.createElement("div");
childrenEl.className = "sm-children";
childrenEl.appendChild(buildTree(node.children));
toggle.addEventListener("click", () => {
childrenEl.classList.toggle("collapsed");
toggle.classList.toggle("open");
toggle.setAttribute("aria-expanded",
String(!childrenEl.classList.contains("collapsed")));
});
row.appendChild(toggle);
} else {
const ph = document.createElement("span");
ph.className = "sm-toggle-placeholder";
row.appendChild(ph);
}
const icon = document.createElement("span");
icon.className = "sm-icon";
icon.textContent = hasChildren ? "📂" : "📄";
row.appendChild(icon);
const labelEl = document.createElement("span");
labelEl.className = "sm-label";
if (node.href) {
const a = document.createElement("a");
a.href = node.href;
a.textContent = node.label;
labelEl.appendChild(a);
} else {
const s = document.createElement("span");
s.textContent = node.label;
labelEl.appendChild(s);
}
row.appendChild(labelEl);
if (hasChildren) {
const badge = document.createElement("span");
badge.className = "sm-badge";
badge.textContent = countLeaves(node.children);
row.appendChild(badge);
}
if (node.date) {
const dateSpan = document.createElement("span");
dateSpan.className = "sm-date";
dateSpan.textContent = node.date;
row.appendChild(dateSpan);
}
if (node.tags && node.tags.length) {
const tagsEl = document.createElement("span");
tagsEl.className = "sm-tags";
node.tags.forEach((t) => {
const tag = document.createElement("span");
tag.className = "sm-tag";
tag.textContent = t;
tagsEl.appendChild(tag);
});
row.appendChild(tagsEl);
}
li.appendChild(row);
if (hasChildren) li.appendChild(childrenEl);
ul.appendChild(li);
}
return ul;
}
treeEl.appendChild(buildTree(tree));
// ── Search / filter ───────────────────────────────────────────────────
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function filterTree(q) {
const query = q.trim().toLowerCase();
const re = query ? new RegExp(escapeRe(query), "gi") : null;
let visible = 0;
function walk(ul) {
let anyVisible = false;
for (const li of ul.children) {
const row = li.querySelector(":scope > .sm-node");
const childrenDiv = li.querySelector(":scope > .sm-children");
const labelEl = row.querySelector(".sm-label");
const target = labelEl.querySelector("a") || labelEl.querySelector("span");
const text = target.dataset.orig || target.textContent;
target.dataset.orig = text;
let selfMatch = false;
if (!query) {
target.innerHTML = "";
target.textContent = text;
selfMatch = true;
} else if (text.toLowerCase().includes(query)) {
target.innerHTML = text.replace(re, (m) => `<mark>${m}</mark>`);
selfMatch = true;
} else {
target.innerHTML = "";
target.textContent = text;
}
let childVisible = false;
if (childrenDiv) {
childVisible = walk(childrenDiv.querySelector("ul"));
if (query) {
childrenDiv.classList.toggle("collapsed", !childVisible && !selfMatch);
const toggle = row.querySelector(".sm-toggle");
if (toggle) toggle.classList.toggle("open", childVisible || selfMatch);
}
}
const show = !query || selfMatch || childVisible;
row.classList.toggle("sm-hidden", !show);
if (show) {
anyVisible = true;
if (!childrenDiv) visible++;
}
}
return anyVisible;
}
walk(treeEl.querySelector("ul"));
countEl.textContent = query
? `${visible} page${visible !== 1 ? "s" : ""} matching "${query}"`
: "";
}
searchEl.addEventListener("input", (e) => filterTree(e.target.value));
wrapper.querySelector(".sm-expand-all").addEventListener("click", () => {
treeEl.querySelectorAll(".sm-children").forEach((el) => el.classList.remove("collapsed"));
treeEl.querySelectorAll(".sm-toggle").forEach((el) => {
el.classList.add("open");
el.setAttribute("aria-expanded", "true");
});
});
wrapper.querySelector(".sm-collapse-all").addEventListener("click", () => {
treeEl.querySelectorAll(".sm-children").forEach((el) => el.classList.add("collapsed"));
treeEl.querySelectorAll(".sm-toggle").forEach((el) => {
el.classList.remove("open");
el.setAttribute("aria-expanded", "false");
});
});
// ── Inject into page ──────────────────────────────────────────────────
if (parsed.wrapOutline) {
// Hide original outline divs (direct children of content only)
contentDiv.querySelectorAll(":scope > .outline-2, :scope > .outline-3")
.forEach((el) => (el.style.display = "none"));
// Insert after title + optional intro <p>
const insertAfter = contentDiv.querySelector(":scope > p") || titleEl;
insertAfter.insertAdjacentElement("afterend", wrapper);
} else {
parsed.replaceTarget.replaceWith(wrapper);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,19 @@
/* Event listener function for the COPY BUTTON */
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll("pre.src").forEach(function (codeBlock) {
const button = document.createElement("button");
button.innerText = "Copy";
button.className = "copy-btn";
// Append button inside <pre>
codeBlock.appendChild(button);
button.addEventListener("click", function () {
const text = codeBlock.innerText.replace(button.innerText, ""); // exclude button text
navigator.clipboard.writeText(text.trim()).then(() => {
button.innerText = "Copied!";
setTimeout(() => (button.innerText = "Copy"), 1500);
});
});
});
});

38
assets/scripts/ui/countdown.js Executable file
View File

@@ -0,0 +1,38 @@
// Simple, timezone-safe if you pass UTC (…Z) in the datetime
document.addEventListener('DOMContentLoaded', () => {
const els = document.querySelectorAll('time.countdown');
if (!els.length) return;
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
const render = (el) => {
const raw = el.getAttribute('datetime');
const label = el.dataset.label || '';
const target = new Date(raw); // Prefer ISO like 2025-12-31T00:00:00Z
if (isNaN(target)) { el.textContent = '—'; return; }
const now = new Date();
let diff = target - now;
if (diff <= 0) {
el.textContent = `${label ? label + ' ' : ''}today`;
el.classList.add('expired');
return;
}
const d = Math.floor(diff / 86400000); diff -= d * 86400000;
const h = Math.floor(diff / 3600000); diff -= h * 3600000;
const m = Math.floor(diff / 60000); diff -= m * 60000;
const s = Math.floor(diff / 1000);
const pieces = [];
if (d) pieces.push(plural(d, 'day'));
pieces.push(`${h}h ${m}m ${s}s`);
el.textContent = `${label ? label + ' in : ' : ''}${pieces.join(' ')}`;
};
const tick = () => els.forEach(render);
tick();
setInterval(tick, 1000); // update every second
});

View File

@@ -0,0 +1,50 @@
/* Event listener for footnotes and sidenotes*/
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('a.footref[href^="#fn"]').forEach(ref => {
const sup = ref.closest("sup") || ref;
// idempotent: don't insert twice
if (sup.nextElementSibling && sup.nextElementSibling.classList?.contains("footnote-sidenote")) return;
const targetId = ref.getAttribute("href").replace(/^#/, ""); // works for fn.2 or fn2
const anchor = document.getElementById(targetId);
if (!anchor) return;
const footdef = anchor.closest(".footdef") || anchor.parentElement;
if (!footdef) return;
// 1) Prefer leaf paragraphs to avoid div+p duplication
let paras = footdef.querySelectorAll("p.footpara");
if (!paras.length) {
// fallback: any .footpara elements that don't contain another .footpara
paras = footdef.querySelectorAll(".footpara:not(:has(.footpara))");
}
// 2) Build HTML, de-duplicating by text content
let parts = [];
if (paras.length) {
const seen = new Set();
parts = Array.from(paras).map(p => {
const txt = p.textContent.trim().replace(/\s+/g, " ");
if (seen.has(txt)) return "";
seen.add(txt);
return p.innerHTML.trim();
}).filter(Boolean);
}
// 3) Fallback: clean full block if no paras found
if (!parts.length) {
const clone = footdef.cloneNode(true);
clone.querySelectorAll("sup.footnum, a[role='doc-backlink']").forEach(n => n.remove());
parts = [clone.innerHTML.trim()];
}
// 4) Insert the sidenote
const sn = document.createElement("span");
sn.className = "sidenote footnote-sidenote";
sn.setAttribute("data-fn", (ref.textContent || "").trim());
sn.innerHTML = parts.join(" ");
sup.insertAdjacentElement("afterend", sn);
});
});

315
assets/scripts/ui/gallery-init.js Executable file
View File

@@ -0,0 +1,315 @@
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.BiggerPicture !== 'function') {
console.error('[gallery-init] BiggerPicture not found. Check script path.');
return;
}
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'm4v', 'ogv'];
const isVideo = (href) => {
if (!href) return false;
href = href.toLowerCase();
return VIDEO_EXTENSIONS.some(ext => href.endsWith('.' + ext));
};
// 1b) Convert MP4 links into inline <video> players
// Convert video links into inline <video> players BUT keep the <a>
const videoLinks = document.querySelectorAll('a[href]');
videoLinks.forEach(a => {
const href = a.getAttribute("href");
if (!isVideo(href)) return;
const video = document.createElement("video");
video.controls = true;
video.preload = "metadata";
video.style.maxWidth = "100%";
video.style.borderRadius = "6px";
const source = document.createElement("source");
source.src = href;
source.type = "video/" + href.split('.').pop(); // guesses correct mime
video.appendChild(source);
// Replace the link with the inline video
a.parentNode.replaceChild(video, a);
});
// 1) Wrap Org-exported images so theyre clickable
const imgs = document.querySelectorAll('.figure img, img.org-svg');
imgs.forEach((img) => {
if (img.closest('a')) return; // already wrapped
const a = document.createElement('a');
const href = img.currentSrc || img.src;
a.href = href;
a.dataset.img = href; // lets BP pre-size/raster slides
a.dataset.alt = img.alt || '';
const setDims = () => {
a.dataset.width = img.naturalWidth || img.width || 1920;
a.dataset.height = img.naturalHeight || img.height || 1080;
};
if (img.complete) setDims(); else img.addEventListener('load', setDims);
img.style.cursor = 'zoom-in';
img.parentElement.insertBefore(a, img);
a.appendChild(img);
});
// 2) One global BP instance
const bp = BiggerPicture({ target: document.body });
// SVG pan/zoom handle
let activePanZoom = null;
const destroyPanZoom = () => { try { activePanZoom?.destroy(); } catch(_){} activePanZoom = null; };
// Simple rotate state (for non-SVG images)
let activeContainer = null;
let currentRotation = 0;
let rotateControls = null;
// 3) Build galleries per content container
const containers = document.querySelectorAll('main, article, .content, body');
containers.forEach((container) => {
const links = Array.from(container.querySelectorAll(
'.figure a, a:has(img.org-svg), a[href$=".mp4"], a[href$=".webm"], a[href$=".mov"], a[href$=".m4v"], a[href$=".ogv"]'
));
if (!links.length) return;
// Start the lightbox on click
links.forEach((link, index) => {
link.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll(".theme-toggle").forEach(el => {
el.classList.add("hidden");
});
bp.open({
// IMPORTANT: pass the anchor ELEMENTS, not custom objects
items: links,
el: link,
caption: (el) => el.querySelector('img')?.alt || el.title || '',
maxZoom: 40, // for raster images (PNG/JPG); SVG handled separately
// Fade-out polish + cleanup
onClose(containerEl) {
destroyPanZoom();
teardownRotation();
if (containerEl) containerEl.classList.add('bp-fadeout');
const themeToggle = document.querySelector(".theme-toggle");
if (themeToggle) {
themeToggle.classList.remove("hidden");
}
},
// Called once after open and on every slide change
//onOpen(containerEl) { setupRotation(containerEl); enhanceSVG(containerEl); }
onOpen(containerEl) {
const linkEl = bp.currItem;
const href = linkEl?.href || "";
if (href.endsWith(".mp4")) {
// Turn off image rotation (not applicable for video)
teardownRotation();
const htmlLayer = containerEl.querySelector(".bp-html");
const imgLayer = containerEl.querySelector(".bp-img");
if (!htmlLayer) return;
// Hide the default image layer
if (imgLayer) imgLayer.style.display = "none";
// Insert video player
htmlLayer.innerHTML = `
<video controls autoplay style="max-width:95vw; max-height:95vh">
<source src="${href}" type="video/mp4">
</video>
`;
return; // Do not run image/SVG enhancements
}
// Image behaviour
setupRotation(containerEl);
enhanceSVG(containerEl);
} ,
onUpdate(containerEl){ setupRotation(containerEl); enhanceSVG(containerEl); }
});
});
});
});
// 4) Simple rotate buttons for raster images
function ensureRotateControls() {
if (rotateControls) return rotateControls;
const wrapper = document.createElement('div');
wrapper.className = 'bp-rotate-controls';
Object.assign(wrapper.style, {
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
display: 'flex',
gap: '0.5rem',
zIndex: '9999',
pointerEvents: 'auto'
});
const mkBtn = (label, title) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = label;
btn.title = title;
btn.setAttribute('aria-label', title);
Object.assign(btn.style, {
padding: '0.4rem 0.6rem',
borderRadius: '999px',
border: 'none',
fontSize: '1.2rem',
cursor: 'pointer',
background: 'rgba(30,30,30,0.8)',
color: '#fff'
});
return btn;
};
const left = mkBtn('⟲', 'Rotate image 90° left');
const right = mkBtn('⟳', 'Rotate image 90° right');
left.addEventListener('click', (e) => {
e.stopPropagation(); // dont close the lightbox
if (!activeContainer) return;
currentRotation = (currentRotation - 90 + 360) % 360;
applyRotation();
});
right.addEventListener('click', (e) => {
e.stopPropagation();
if (!activeContainer) return;
currentRotation = (currentRotation + 90) % 360;
applyRotation();
});
wrapper.append(left, right);
document.body.appendChild(wrapper);
rotateControls = wrapper;
return rotateControls;
}
function ensureRotateWrapper() {
if (!activeContainer) return null;
const imgRoot = activeContainer.querySelector('.bp-img');
if (!imgRoot) return null;
let imgEl = imgRoot.querySelector('img');
if (!imgEl) return null;
const src = (imgEl.currentSrc || imgEl.src || '').toLowerCase();
// We only rotate raster images; SVGs are handled via svg-pan-zoom
if (src.endsWith('.svg')) return null;
let wrapper = imgRoot.querySelector('.bp-rotate-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'bp-rotate-wrapper';
wrapper.style.display = 'inline-block';
wrapper.style.transformOrigin = 'center center';
imgRoot.appendChild(wrapper);
wrapper.appendChild(imgEl);
} else if (!wrapper.contains(imgEl)) {
// Slide changed and BiggerPicture replaced the <img>
wrapper.innerHTML = '';
wrapper.appendChild(imgEl);
}
return wrapper;
}
function applyRotation() {
const wrapper = ensureRotateWrapper();
if (!wrapper) return;
wrapper.style.transform = `rotate(${currentRotation}deg)`;
}
function setupRotation(containerEl) {
activeContainer = containerEl;
currentRotation = 0;
const controls = ensureRotateControls();
controls.style.display = 'flex';
applyRotation();
}
function teardownRotation() {
activeContainer = null;
currentRotation = 0;
if (rotateControls) {
rotateControls.style.display = 'none';
}
}
// 4) If current slide is an SVG, swap to inline + enable svg-pan-zoom
async function enhanceSVG(containerEl) {
try {
destroyPanZoom();
const imgEl = containerEl.querySelector('.bp-img img');
if (!imgEl) return;
const src = imgEl.currentSrc || imgEl.src || '';
const isSVG = src.toLowerCase().endsWith('.svg');
const htmlLayer = containerEl.querySelector('.bp-html');
if (!isSVG || !htmlLayer) {
// ensure any previous holder is removed and bitmap is visible
const old = htmlLayer?.querySelector('.bp-svg-holder');
if (old) old.remove();
imgEl.style.visibility = '';
return;
}
// Create/clear holder
let holder = htmlLayer.querySelector('.bp-svg-holder');
if (!holder) {
holder = document.createElement('div');
holder.className = 'bp-svg-holder';
holder.style.maxWidth = '95vw';
holder.style.maxHeight = '95vh';
htmlLayer.appendChild(holder);
}
holder.innerHTML = '';
// Hide the bitmap so only the inline SVG shows
imgEl.style.visibility = 'hidden';
// Inline the SVG
const res = await fetch(src, { cache: 'force-cache' });
const text = await res.text();
holder.innerHTML = text;
const svg = holder.querySelector('svg');
if (!svg) { imgEl.style.visibility = ''; return; }
svg.style.maxWidth = '95vw';
svg.style.maxHeight = '95vh';
svg.style.display = 'block';
if (typeof window.svgPanZoom === 'function') {
activePanZoom = svgPanZoom(svg, {
zoomEnabled: true,
controlIconsEnabled: true,
fit: true,
center: true,
minZoom: 0.05,
maxZoom: 400, // effectively "unlimited"
zoomScaleSensitivity: 0.25,
dblClickZoomEnabled: true
});
// Keep wheel inside lightbox
holder.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true });
} else {
console.warn('[gallery-init] svg-pan-zoom not loaded');
}
} catch (err) {
console.error('[gallery-init] SVG enhance failed:', err);
}
}
});

View File

@@ -0,0 +1,101 @@
// Make Mermaid diagrams use the active site theme and zoom controls.
(function () {
function cssVar(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function mermaidConfig() {
return {
startOnLoad: false,
theme: "base",
themeVariables: {
background: cssVar("--bg", "#faf9f6"),
mainBkg: cssVar("--surface", "#ffffff"),
primaryColor: cssVar("--surface", "#ffffff"),
primaryTextColor: cssVar("--fg", "#000000"),
primaryBorderColor: cssVar("--accent", "#2563eb"),
secondaryColor: cssVar("--surface-soft", "#f3f1eb"),
tertiaryColor: cssVar("--bg", "#faf9f6"),
clusterBkg: cssVar("--surface-soft", "#f3f1eb"),
clusterBorder: cssVar("--border", "#d7d7d7"),
lineColor: cssVar("--border", "#d7d7d7"),
textColor: cssVar("--fg", "#000000"),
edgeLabelBackground: cssVar("--surface", "#ffffff"),
fontFamily: cssVar("--font-body", "Noto, system-ui, sans-serif")
},
flowchart: {
htmlLabels: true,
curve: "basis"
}
};
}
function decodeMermaidSource(source) {
const textarea = document.createElement("textarea");
textarea.innerHTML = source;
return textarea.value.trim();
}
async function renderMermaid() {
if (!window.mermaid) return;
const diagrams = document.querySelectorAll(".mermaid");
if (!diagrams.length) return;
diagrams.forEach((el) => {
if (!el.dataset.source) {
el.dataset.source = decodeMermaidSource(el.textContent || el.innerHTML);
}
el.removeAttribute("data-processed");
el.innerHTML = el.dataset.source;
});
mermaid.initialize(mermaidConfig());
await mermaid.run({ querySelector: ".mermaid" });
wrapMermaidDiagrams();
}
function wrapMermaidDiagrams() {
document.querySelectorAll(".mermaid").forEach((el) => {
if (el.closest(".mermaid-container")) return;
const container = document.createElement("div");
container.className = "mermaid-container";
if ((el.dataset.source || "").includes('root(["zxh"])')) {
container.classList.add("mermaid-container--site-map");
}
const controls = document.createElement("div");
controls.className = "mermaid-zoom-controls";
controls.innerHTML = `
<button class="zoom-in" type="button" aria-label="Zoom in">+</button>
<button class="zoom-out" type="button" aria-label="Zoom out">-</button>
`;
el.replaceWith(container);
container.appendChild(controls);
container.appendChild(el);
let scale = 1;
const setScale = (next) => {
const svg = container.querySelector(".mermaid svg");
if (!svg) return;
scale = Math.max(0.2, Math.min(3, next));
svg.style.transform = `scale(${scale})`;
};
controls.querySelector(".zoom-in").addEventListener("click", () => setScale(scale + 0.1));
controls.querySelector(".zoom-out").addEventListener("click", () => setScale(scale - 0.1));
container.addEventListener("wheel", (e) => {
if (!e.ctrlKey) return;
e.preventDefault();
setScale(scale + (e.deltaY < 0 ? 0.05 : -0.05));
});
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", renderMermaid);
} else {
renderMermaid();
}
})();

View File

@@ -0,0 +1,88 @@
/* Event listener for scrolling and changing the active label on the TOC */
document.addEventListener("DOMContentLoaded", () => {
const toc = document.querySelector("#text-table-of-contents");
if (!toc) return;
const links = toc.querySelectorAll('a[href^="#"]'); // '^=' is a starts with operator.
// <a href="#introduction">Intro</a> matches
if (!links.length) return;
// Map: id -> link
const linkById = new Map();
links.forEach(a => {
const id = decodeURIComponent(a.getAttribute("href").slice(1));
const el = document.getElementById(id);
if (el) linkById.set(id, a);
});
if (!linkById.size) return;
// Headings to observe (h2h4 usually)
const headings = [...document.querySelectorAll("h2[id], h3[id], h4[id]")]
.filter(h => linkById.has(h.id));
// Helper to mark active
const setActive = (id) => {
links.forEach(a => {
const active = a.getAttribute("href") === `#${id}`;
a.classList.toggle("is-active", active);
if (active) a.setAttribute("aria-current", "true");
else a.removeAttribute("aria-current");
});
};
// Calculate sticky header offset in px
const headerOffsetPx = 6.5 * parseFloat(getComputedStyle(document.documentElement).fontSize);
// Track visible headings (id -> distance from top)
const visible = new Map();
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const id = entry.target.id;
if (entry.isIntersecting) {
// How far from the top (after header offset)
const dist = entry.target.getBoundingClientRect().top - headerOffsetPx;
visible.set(id, dist);
} else {
visible.delete(id);
}
});
if (visible.size) {
// Choose the heading closest to the top (>= -headerOffset)
const topMost = [...visible.entries()]
.sort((a,b) => Math.abs(a[1]) - Math.abs(b[1]))[0][0];
setActive(topMost);
// console.log("Active:", topMost, visible);
}
}, {
root: null, // track relative to viewport
rootMargin: `-${headerOffsetPx}px 0px -70% 0px`,
threshold: [0, 0.01, 0.1] // fire as soon as it enters
});
headings.forEach(h => observer.observe(h));
// Initial highlight (in case load midpage)
let bestId = null, bestDist = Infinity;
headings.forEach(h => {
const top = h.getBoundingClientRect().top - headerOffsetPx;
const dist = top < 0 ? Math.abs(top) : top + 1e6;
if (dist < bestDist) { bestDist = dist; bestId = h.id; }
});
if (bestId) setActive(bestId);
// smooth-scroll ToC clicks
toc.addEventListener("click", (e) => {
const a = e.target.closest('a[href^="#"]');
if (!a) return;
const id = decodeURIComponent(a.hash.slice(1));
const el = document.getElementById(id);
if (!el) return;
e.preventDefault();
el.scrollIntoView({ behavior: "smooth", block: "start" });
el.setAttribute("tabindex", "-1");
el.focus({ preventScroll: true });
history.pushState(null, "", `#${id}`);
});
});

1
assets/scripts/vendor/bigger-picture.min.js vendored Executable file

File diff suppressed because one or more lines are too long

3475
assets/scripts/vendor/lunr.js vendored Executable file

File diff suppressed because it is too large Load Diff

2811
assets/scripts/vendor/mermaid.min.js vendored Executable file

File diff suppressed because one or more lines are too long

22
assets/scripts/vendor/pdf.min.js vendored Executable file

File diff suppressed because one or more lines are too long

22
assets/scripts/vendor/pdf.worker.min.js vendored Executable file

File diff suppressed because one or more lines are too long

177
assets/scripts/vendor/pdfjs-LICENSE.txt vendored Executable file
View File

@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

27
assets/scripts/vendor/svg-pan-zoom.min.js vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,313 +1,7 @@
/* Home dashboard behaviour. All selectors are db-* scoped. */
(function () { (function () {
"use strict"; "use strict";
var script = document.createElement("script");
function updateGreeting() { script.src = "/assets/scripts/pages/home-dashboard.js";
const el = document.getElementById("db-greeting"); script.defer = true;
if (!el) return; document.head.appendChild(script);
}());
const hour = new Date().getHours();
if (hour < 5) el.textContent = "Late session";
else if (hour < 12) el.textContent = "Good morning";
else if (hour < 17) el.textContent = "Good afternoon";
else if (hour < 21) el.textContent = "Good evening";
else el.textContent = "Evening review";
}
function updateClock() {
const el = document.getElementById("db-clock");
if (!el) return;
el.textContent = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date());
}
function getIsoWeek(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
}
function updateStats() {
const now = new Date();
const year = now.getFullYear();
const start = new Date(year, 0, 1);
const nextYear = new Date(year + 1, 0, 1);
const dayOfYear = Math.floor((now - start) / 86400000) + 1;
const daysInYear = Math.round((nextYear - start) / 86400000);
const daysLeft = Math.max(0, daysInYear - dayOfYear);
const month = new Intl.DateTimeFormat(undefined, { month: "short" }).format(now);
const pct = ((dayOfYear / daysInYear) * 100).toFixed(1);
setText("db-stat-day", dayOfYear);
setText("db-stat-week", "W" + getIsoWeek(now));
setText("db-stat-month", month);
setText("db-stat-left", daysLeft);
setText("db-year-pct", pct + "%");
const fill = document.getElementById("db-year-fill");
if (fill) fill.style.width = pct + "%";
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function escHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escAttr(str) {
return escHtml(str).replace(/'/g, "&#39;");
}
function normaliseHref(href, prefix) {
if (!href || href.startsWith("http") || href.startsWith("#")) return null;
if (href.startsWith("/")) return href;
const base = (prefix || "").replace(/\/$/, "");
if (!base || href.startsWith(base + "/")) return href;
return base + "/" + href;
}
function isTagLink(a) {
const href = a.getAttribute("href") || "";
return href.includes("/tags/") || Boolean(a.querySelector(".post-tag"));
}
function extractLinks(doc, max, prefix) {
const items = [];
const seen = new Set();
const links = doc.querySelectorAll("#content li a[href], .org-ul li a[href], ul li a[href]");
for (const a of links) {
if (isTagLink(a)) continue;
const href = normaliseHref(a.getAttribute("href"), prefix);
const title = a.textContent.trim();
if (!href || !title || seen.has(href)) continue;
const li = a.closest("li");
const dateMatch = li ? li.textContent.match(/\b\d{4}-\d{2}-\d{2}\b/) : null;
seen.add(href);
items.push({ href, title, date: dateMatch ? dateMatch[0] : null });
if (items.length >= max) break;
}
return items;
}
function renderFeed(ulId, items) {
const ul = document.getElementById(ulId);
if (!ul || !items.length) return;
ul.innerHTML = items
.map(({ href, title, date }) => (
`<li class="db-feed__item">` +
`<span class="db-feed__dot"></span>` +
`<a href="${escHtml(href)}">${escHtml(title)}</a>` +
(date ? `<span class="db-feed__meta">${escHtml(date)}</span>` : "") +
`</li>`
))
.join("");
}
async function loadFeed(listUrl, ulId, max, prefix) {
try {
const resp = await fetch(listUrl, { credentials: "same-origin" });
if (!resp.ok) return;
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, "text/html");
renderFeed(ulId, extractLinks(doc, max, prefix));
} catch (_) {
/* Fallback links remain in the HTML. */
}
}
function commentSlug(comment) {
return comment.pageSlug || comment.page_slug || "";
}
function commentDate(comment) {
return comment.created_at || comment.createdAt || "";
}
function commentHref(page, comment) {
if (!page?.url) return null;
if (!comment.id) return page.url + "#comments";
return page.url + "#comment-" + encodeURIComponent(comment.id);
}
function formatCommentDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function commentExcerpt(content) {
const text = String(content || "").replace(/\s+/g, " ").trim();
if (text.length <= 150) return text;
return text.slice(0, 147).trimEnd() + "...";
}
function renderRecentComments(comments, pageMap) {
const ul = document.getElementById("db-feed-comments");
if (!ul) return;
if (!comments.length) {
ul.innerHTML = (
`<li class="db-comment-feed__item">` +
`<span class="db-feed__dot"></span>` +
`<div class="db-comment-feed__body">` +
`<span class="db-comment-feed__empty">No comments yet.</span>` +
`</div>` +
`</li>`
);
return;
}
ul.innerHTML = comments.map((comment) => {
const slug = commentSlug(comment);
const page = pageMap.get(slug);
const title = page?.title || slug || "Unknown page";
const href = commentHref(page, comment);
const author = comment.author || "Anonymous";
const date = formatCommentDate(commentDate(comment));
const pageLink = href
? `<a class="db-comment-feed__page" href="${escAttr(href)}">${escHtml(title)}</a>`
: `<span class="db-comment-feed__page">${escHtml(title)}</span>`;
return (
`<li class="db-comment-feed__item">` +
`<span class="db-feed__dot"></span>` +
`<div class="db-comment-feed__body">` +
`<div class="db-comment-feed__top">` +
`<strong>${escHtml(author)}</strong>` +
`<span>on</span>` +
pageLink +
(date ? `<time datetime="${escAttr(commentDate(comment))}">${escHtml(date)}</time>` : "") +
`</div>` +
`<p>${escHtml(commentExcerpt(comment.content))}</p>` +
`</div>` +
`</li>`
);
}).join("");
}
async function loadCommentPageMap() {
try {
const resp = await fetch("/assets/content/comment-pages.json", { credentials: "same-origin" });
if (!resp.ok) return new Map();
const pages = await resp.json();
return new Map(
pages
.filter((page) => page.slug && page.url)
.map((page) => [page.slug, page])
);
} catch (_) {
return new Map();
}
}
async function loadRecentComments() {
const ul = document.getElementById("db-feed-comments");
if (!ul) return;
const renderUnavailable = () => {
ul.innerHTML = (
`<li class="db-comment-feed__item">` +
`<span class="db-feed__dot"></span>` +
`<div class="db-comment-feed__body">` +
`<span class="db-comment-feed__empty">Recent comments are unavailable.</span>` +
`</div>` +
`</li>`
);
};
try {
const [pageMap, resp] = await Promise.all([
loadCommentPageMap(),
fetch("/api/comments", { credentials: "same-origin" }),
]);
if (!resp.ok) {
renderUnavailable();
return;
}
const comments = await resp.json();
const recent = comments
.filter((comment) => commentDate(comment))
.sort((a, b) => new Date(commentDate(b)) - new Date(commentDate(a)))
.slice(0, 10);
renderRecentComments(recent, pageMap);
} catch (_) {
renderUnavailable();
}
}
function initCommandFilter() {
const input = document.getElementById("db-command-search");
const nav = document.getElementById("db-quicknav");
if (!input || !nav) return;
const items = Array.from(nav.querySelectorAll(".db-qn-item"));
const applyFilter = () => {
const query = input.value.trim().toLowerCase();
items.forEach((item) => {
const haystack = [
item.textContent,
item.getAttribute("href"),
item.dataset.keywords,
].join(" ").toLowerCase();
item.classList.toggle("is-hidden", Boolean(query && !haystack.includes(query)));
});
};
input.addEventListener("input", applyFilter);
document.addEventListener("keydown", (event) => {
if (event.key === "/" && !/^(input|textarea|select)$/i.test(event.target.tagName)) {
event.preventDefault();
input.focus();
}
});
}
function init() {
updateGreeting();
updateClock();
updateStats();
initCommandFilter();
setInterval(updateClock, 1000);
loadFeed("/blogs/blogs-list.html", "db-feed-blogs", 6, "/blogs");
loadFeed("/posts/posts-list.html", "db-feed-posts", 6, "/posts");
loadFeed("/recently-updated.html", "db-feed-recent", 6, "");
loadRecentComments();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

226
assets/styles/base/elements.css Executable file
View File

@@ -0,0 +1,226 @@
@font-face {
font-family: 'Noto';
src: url('../../fonts/NotoSans-Regular.ttf') format('truetype');
}
html, body{
background:
radial-gradient(circle at 18% 12%, var(--theme-glow-wine, transparent), transparent 28%),
radial-gradient(circle at 84% 18%, var(--theme-glow-green, transparent), transparent 24%),
linear-gradient(135deg, var(--theme-bg-start, var(--bg)) 0%, var(--theme-bg-mid, var(--bg)) 42%, var(--theme-bg-end, var(--bg)) 100%);
color: var(--fg);
font-size: 16px;
transition: background-color .3s, color .3s;
margin: 0;
font-family: 'Noto', system-ui, sans-serif;
line-height: 1.65;
}
body {
min-height: 100vh;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
:root[data-theme="dark-academia"] body {
--theme-glow-wine: rgba(125, 47, 54, 0.24);
--theme-glow-green: rgba(68, 106, 87, 0.24);
--theme-bg-start: #0f0e0b;
--theme-bg-mid: #17140f;
--theme-bg-end: #211b14;
}
:root[data-theme="dark-academia"] body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.16;
background-image:
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
background-size: 34px 34px, 34px 34px;
mask-image: linear-gradient(90deg, rgba(0,0,0,0.9), rgba(0,0,0,0.35));
}
::selection {
background: color-mix(in oklab, var(--accent) 24%, transparent);
}
h1, h2, h3{
color: var(--heading);
line-height: 1.18;
letter-spacing: 0;
}
h1 {
margin-top: 2.25rem;
margin-bottom: 1rem;
font-size: clamp(2rem, 4vw, 3.4rem);
}
h2 {
margin-top: 2.2rem;
}
p {
margin: 0 0 1.15rem;
}
a {
color: var(--link);
text-decoration: none;
text-underline-offset: 0.18em;
}
a:hover {
text-decoration: underline;
}
hr {
border: 0;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
overflow: hidden;
}
th,
td {
border-bottom: 1px solid var(--border);
padding: 0.65rem 0.75rem;
text-align: left;
}
th {
color: var(--heading);
background: color-mix(in oklab, var(--surface-soft) 80%, transparent);
}
blockquote {
margin: 1.5rem 0;
padding: 0.9rem 1.1rem;
border-left: 4px solid var(--heading);
background: color-mix(in oklab, var(--surface-soft) 72%, transparent);
border-radius: 0 8px 8px 0;
color: color-mix(in oklab, var(--fg) 84%, var(--muted) 16%);
}
ul, ol {
margin: 1rem 0 1.5rem 1.5rem;
padding: 0;
line-height: 1.5;
}
ul {
list-style: none;
}
ul li {
position: relative;
padding-left: 1.2em;
}
ul li::before {
content: "•";
position: absolute;
left: 0;
top: 0;
color: var(--heading);
font-weight: bold;
}
ol {
counter-reset: list-counter;
list-style: none;
}
ol li {
counter-increment: list-counter;
position: relative;
padding-left: 1.8em;
}
ol li::before {
content: counter(list-counter) ".";
position: absolute;
left: 0;
top: 0;
color: var(--heading);
font-weight: bold;
}
li {
margin-bottom: 8px;
display: flow-root;
}
li::after {
content: none;
}
li ul,
li ol {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
li ul li::before {
content: "";
font-weight: normal;
color: var(--note-color);
}
li ol li::before {
font-weight: normal;
color: var(--note-color);
}
.epigraph {
margin: 2rem auto;
max-width: 80%;
font-style: italic;
}
.epigraph blockquote {
margin: 0;
padding: 1rem 1.5rem;
border-left: 4px solid var(--heading);
background-color: rgba(0, 0, 0, 0.02);
color: var(--fg);
line-height: 1.6;
}
.epigraph blockquote footer {
margin-top: 0.75rem;
font-style: normal;
font-size: 0.9em;
color: var(--note-color);
text-align: right;
}
.epigraph blockquote cite {
font-style: italic;
font-weight: 500;
color: var(--link);
}
.epigraph blockquote::before,
.epigraph blockquote::after {
content: none;
}
.epigraph blockquote{
border-left-color: var(--heading);
background-color: color-mix(in oklab, var(--bg) 94%, var(--fg) 6%);
color: var(--fg);
}
.epigraph blockquote footer{ color: var(--muted); }

View File

@@ -1,8 +1 @@
/** @import url("./vendor/bigger-picture.min.css");
* Minified by jsDelivr using clean-css v5.3.2.
* Original file: /npm/bigger-picture@1.1.19/dist/bigger-picture.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
@keyframes bp-fadein{from{opacity:.01}to{opacity:1}}@keyframes bp-bar{from{transform:translateX(-100%)}to{transform:translateX(0)}}@keyframes bp-o{from{transform:rotate(0)}to{transform:rotate(360deg)}}.bp-wrap{top:0;left:0;width:100%;height:100%;position:fixed;z-index:999;contain:strict;touch-action:none;-webkit-tap-highlight-color:transparent}.bp-wrap>div:first-child{position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.75);animation:bp-fadein .48s cubic-bezier(.215,.61,.355,1)}.bp-vid audio{position:absolute;left:14px;width:calc(100% - 28px);bottom:14px;height:50px}.bp-inner{top:0;left:0;width:100%;height:100%;position:absolute;display:flex}.bp-html{display:contents}.bp-html>:first-child{margin:auto}.bp-img-wrap{top:0;left:0;width:100%;height:100%;position:absolute;contain:strict}.bp-img-wrap .bp-canzoom{cursor:zoom-in}.bp-img-wrap .bp-drag{cursor:grabbing}.bp-close{contain:layout size}.bp-img{position:absolute;top:50%;left:50%;user-select:none;background-size:100% 100%}.bp-img div,.bp-img img{position:absolute;top:0;left:0;width:100%;height:100%}.bp-img .bp-o{display:none}.bp-zoomed .bp-img:not(.bp-drag){cursor:grab}.bp-zoomed .bp-cap{opacity:0;animation:none!important}.bp-zoomed.bp-small .bp-controls{opacity:0}.bp-zoomed.bp-small .bp-controls button{pointer-events:none}.bp-controls{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;text-align:left;transition:opacity .3s;animation:bp-fadein .3s}.bp-controls button{pointer-events:auto;cursor:pointer;position:absolute;border:0;background:rgba(0,0,0,.15);opacity:.9;transition:all .1s;contain:content}.bp-controls button:hover{background-color:rgba(0,0,0,.2);opacity:1}.bp-controls svg{fill:#fff}.bp-count{position:absolute;color:rgba(255,255,255,.9);line-height:1;margin:16px;height:50px;width:100px}.bp-next,.bp-prev{top:50%;right:0;margin-top:-32px;height:64px;width:58px;border-radius:3px 0 0 3px}.bp-next:hover:before,.bp-prev:hover:before{transform:translateX(-2px)}.bp-next:before,.bp-prev:before{content:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23fff'%3E%3Cpath d='M8.59 16.34l4.58-4.59-4.58-4.59L10 5.75l6 6-6 6z'/%3E%3C/svg%3E");position:absolute;left:7px;top:9px;width:46px;transition:all .2s}.bp-prev{right:auto;left:0;transform:scalex(-1)}.bp-x{top:0;right:0;height:55px;width:58px;border-radius:0 0 0 3px}.bp-x:before{content:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23fff'%3E%3Cpath d='M24 10l-2-2-6 6-6-6-2 2 6 6-6 6 2 2 6-6 6 6 2-2-6-6z'/%3E%3C/svg%3E");position:absolute;width:37px;top:8px;right:10px}.bp-if,.bp-vid{position:relative;margin:auto;background:#000;background-size:100% 100%}.bp-if div,.bp-if iframe,.bp-if video,.bp-vid div,.bp-vid iframe,.bp-vid video{top:0;left:0;width:100%;height:100%;position:absolute;border:0}.bp-load{display:flex;background-size:100% 100%;overflow:hidden;z-index:1}.bp-bar{position:absolute;top:0;left:0;height:3px;width:100%;transform:translateX(-100%);background:rgba(255,255,255,.9);border-radius:0 3px 3px 0;animation:bp-bar 4s both}.bp-o,.bp-o:after{border-radius:50%;width:90px;height:90px}.bp-o{margin:auto;border:10px solid rgba(255,255,255,.2);border-left-color:rgba(255,255,255,.9);animation:bp-o 1s infinite linear}.bp-cap{position:absolute;bottom:2%;background:rgba(9,9,9,.8);color:rgba(255,255,255,.9);border-radius:4px;max-width:95%;line-height:1.3;padding:.6em 1.2em;left:50%;transform:translateX(-50%);width:fit-content;width:-moz-fit-content;display:table;transition:opacity .3s;animation:bp-fadein .2s}.bp-cap a{color:inherit}.bp-inline{position:absolute}.bp-lock{overflow-y:hidden}.bp-lock body{overflow:scroll}.bp-noclose .bp-x{display:none}.bp-noclose:not(.bp-zoomed){touch-action:pan-y}.bp-noclose:not(.bp-zoomed) .bp-img-wrap{cursor:zoom-in}@media (prefers-reduced-motion){.bp-wrap *{animation-duration:0s!important}}@media (max-width:500px){.bp-x{height:47px;width:47px}.bp-x:before{width:34px;top:6px;right:6px}.bp-next,.bp-prev{margin-top:-27px;height:54px;width:45px}.bp-next:before,.bp-prev:before{top:7px;left:2px;width:43px}.bp-o,.bp-o:after{border-width:6px;width:60px;height:60px}.bp-count{margin:12px 10px}}
/*# sourceMappingURL=/sm/15e96278e1e731ce40eef8d6284cefc81b81dda67c3a0aa386ec893f183bd57f.map */

View File

@@ -1,118 +1 @@
.comments { @import url("./components/comments.css");
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid var(--border-muted, #ddd);
}
.comments h2 {
margin: 0 0 1rem 0;
font-size: 1.4rem;
}
.comments-list {
margin: 1.5rem 0 2rem;
}
.comments-empty {
font-style: italic;
color: var(--text-muted, #777);
}
.comment {
margin-bottom: 1.5rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--border-muted, #eee);
}
.comment:last-child {
border-bottom: none;
}
.comment strong {
display: block;
font-weight: 600;
margin-bottom: 0.25rem;
}
.comment p {
margin: 0.25rem 0 0.5rem;
}
.comment-date {
display: block;
font-size: 0.8rem;
color: var(--text-muted, #777);
}
.comment-children {
margin-top: 1rem;
margin-left: 1.25rem;
padding-left: 1rem;
border-left: 2px solid var(--border-muted, #ddd);
}
.comment-reply {
margin-top: 0.4rem;
padding: 0;
font-size: 0.85rem;
background: none;
border: none;
color: var(--text-muted, #555);
cursor: pointer;
}
.comment-reply:hover {
text-decoration: underline;
}
.comment-form {
max-width: 40rem;
}
.comment-author {
display: block;
margin-bottom: 1rem;
}
.comment-form span {
display: block;
margin-bottom: 0.25rem;
font-size: 0.85rem;
color: var(--fg);
}
.comment-form input,
.comment-form textarea {
width: 100%;
padding: 0;
font-family: inherit;
font-size: 1rem;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border-muted, #ccc);
border-radius: 3px;
}
.comment-form textarea {
min-height: 6rem;
resize: vertical;
}
.comment-form button {
margin-top: 0.75rem;
padding: 0.45rem 1rem;
font-family: inherit;
font-size: 0.9rem;
color: var(--fg);
background: transparent;
border: 1px solid var(--border-muted, #aaa);
cursor: pointer;
}

View File

@@ -0,0 +1,118 @@
.comments {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid var(--border-muted, #ddd);
}
.comments h2 {
margin: 0 0 1rem 0;
font-size: 1.4rem;
}
.comments-list {
margin: 1.5rem 0 2rem;
}
.comments-empty {
font-style: italic;
color: var(--text-muted, #777);
}
.comment {
margin-bottom: 1.5rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--border-muted, #eee);
}
.comment:last-child {
border-bottom: none;
}
.comment strong {
display: block;
font-weight: 600;
margin-bottom: 0.25rem;
}
.comment p {
margin: 0.25rem 0 0.5rem;
}
.comment-date {
display: block;
font-size: 0.8rem;
color: var(--text-muted, #777);
}
.comment-children {
margin-top: 1rem;
margin-left: 1.25rem;
padding-left: 1rem;
border-left: 2px solid var(--border-muted, #ddd);
}
.comment-reply {
margin-top: 0.4rem;
padding: 0;
font-size: 0.85rem;
background: none;
border: none;
color: var(--text-muted, #555);
cursor: pointer;
}
.comment-reply:hover {
text-decoration: underline;
}
.comment-form {
max-width: 40rem;
}
.comment-author {
display: block;
margin-bottom: 1rem;
}
.comment-form span {
display: block;
margin-bottom: 0.25rem;
font-size: 0.85rem;
color: var(--fg);
}
.comment-form input,
.comment-form textarea {
width: 100%;
padding: 0;
font-family: inherit;
font-size: 1rem;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border-muted, #ccc);
border-radius: 3px;
}
.comment-form textarea {
min-height: 6rem;
resize: vertical;
}
.comment-form button {
margin-top: 0.75rem;
padding: 0.45rem 1rem;
font-family: inherit;
font-size: 0.9rem;
color: var(--fg);
background: transparent;
border: 1px solid var(--border-muted, #aaa);
cursor: pointer;
}

View File

@@ -0,0 +1,251 @@
#kanban-board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.kanban-column {
background: #f8f9fa;
border-radius: 8px;
padding: 0.6rem;
display: flex;
flex-direction: column;
}
.kanban-column h3 {
text-align: center;
font-size: 0.9rem;
margin: 0.4rem 0 0.6rem;
font-weight: 600;
}
.kanban-list {
flex-grow: 1;
min-height: 3rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kanban-card {
background: var(--bg);
border-radius: 6px;
padding: 0.5rem 0.6rem;
font-size: 0.85rem;
cursor: grab;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
color: var(--fg);
}
.kanban-card.dragging {
opacity: 0.5;
cursor: grabbing;
}
.kanban-column[data-state="completed"] {
background: var(--kb-bg-completed);
}
.kanban-column[data-state="manager_review"] {
background: var(--kb-bg-manager-review);
}
.kanban-column[data-state="in_progress"] {
background: var(--kb-bg-in-progress);
}
.kanban-column[data-state="not_started"] {
background: var(--kb-bg-not-started);
}
.kanban-column[data-state="comments"] {
background: var(--kb-bg-comments);
}
#mobile-move-panel {
display: none;
max-width: 22rem;
margin: 1.5rem auto 0 auto;
padding: 1rem;
box-sizing: border-box;
border: 1px solid var(--border-color, #ddd);
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
overflow: hidden;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
font-size: 0.9rem;
}
#mobile-move-panel * {
box-sizing: border-box;
}
#mobile-move-panel h4 {
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
font-weight: 600;
}
#mobile-move-panel label {
display: flex;
flex-direction: column;
gap: 0.35rem;
width: 100%;
}
#mobile-move-panel select,
#mobile-move-panel button {
font-size: 0.9rem;
padding: 0.4rem 0.5rem;
min-width: 0;
}
#mobile-move-panel button {
margin-top: 0.5rem;
align-self: flex-start;
}
#mobile-move-panel select {
width: 100%;
max-width: 100%;
padding: 0.45rem 2rem 0.45rem 0.6rem;
font-size: 0.9rem;
line-height: 1.2;
border: 1px solid var(--border-color, #ccc);
border-radius: 0.4rem;
background-color: var(--bg, #fff);
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background-image:
linear-gradient(45deg, transparent 50%, #666 50%),
linear-gradient(135deg, #666 50%, transparent 50%),
linear-gradient(to right, transparent, transparent);
background-position:
calc(100% - 1.2rem) 50%,
calc(100% - 0.9rem) 50%,
100% 0;
background-size:
6px 6px,
6px 6px,
2.5rem 100%;
background-repeat: no-repeat;
margin-bottom: 1em;
}
#mobile-move-panel select:focus {
outline: none;
border-color: #666;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
}
#mobile-move-panel button {
width: 100%;
padding: 0.5rem 0.6rem;
font-size: 0.9rem;
border-radius: 0.4rem;
border: 1px solid #888;
background: #f8f8f8;
cursor: pointer;
}
#mobile-move-panel button:active {
background: #eee;
}
#mobile-move-panel select:disabled {
background-color: #f3f3f3;
color: #555;
border-color: #ccc;
opacity: 1;
cursor: default;
background-image: none;
}
#progress-wrapper {
margin-bottom: 16px;
}
#progress-label {
font-size: 14px;
margin-bottom: 6px;
text-align: right;
color: #555;
}
#progress-bar {
width: 100%;
height: 10px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
}
#progress-fill {
height: 100%;
width: 0%;
background: #22c55e;
transition: width 0.3s ease;
}
#level-tabs {
display: inline-flex;
align-items: center;
gap: 0;
margin-bottom: 1rem;
border-radius: 999px;
padding: 3px;
background: var(--bg, #fff);
width: fit-content;
}
.level-tab {
border: none;
background: transparent;
padding: 0.35rem 1rem;
font-size: 0.85rem;
border-radius: 999px;
cursor: pointer;
color: var(--fg, #333);
transition:
background 0.2s ease,
color 0.2s ease,
transform 0.1s ease;
}
.level-tab:hover {
background: rgba(0,0,0,0.05);
}
.level-tab.active {
background: #333;
color: white;
font-weight: 500;
}
.level-tab:active {
transform: scale(0.97);
}

View File

@@ -0,0 +1,76 @@
/* Mermaid container for zooming and panning */
.mermaid-container {
overflow: auto;
width: 100%;
min-height: 26rem;
max-height: 800px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 88%, var(--bg) 12%);
position: relative;
margin: 1.5rem 0;
}
/* Make SVG scale properly */
.mermaid-container .mermaid svg {
transform-origin: top left; /* needed for scaling */
transition: transform 0.2s ease; /* smooth zoom */
width: 100%;
height: auto;
}
.mermaid-container--site-map {
width: min(96vw, 1400px);
height: min(82vh, 980px);
max-height: none;
margin-left: 50%;
transform: translateX(-50%);
}
.mermaid-container--site-map .mermaid {
display: inline-block;
min-width: max-content;
padding: 3rem 2rem 2rem;
}
.mermaid-container--site-map .mermaid svg {
width: auto;
max-width: none;
min-width: 1100px;
}
@media (max-width: 700px) {
.mermaid-container--site-map {
width: 100%;
height: 78vh;
margin-left: 0;
transform: none;
}
.mermaid-container--site-map .mermaid svg {
min-width: 900px;
}
}
/* Zoom buttons */
.mermaid-zoom-controls {
position: absolute;
top: 5px;
right: 5px;
z-index: 10;
}
.mermaid-zoom-controls button {
padding: 5px 10px;
margin-left: 5px;
background: var(--surface);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.mermaid-zoom-controls button:hover {
background: var(--accent);
color: var(--surface);
}

View File

@@ -0,0 +1,281 @@
/* =========================
NOTES BOARD
========================= */
#notes-tools {
display: flex;
align-items: end;
justify-content: flex-end;
gap: 0.65rem;
margin: 1.25rem 0 0;
}
#notes-tools label {
color: var(--muted);
font-size: 0.75rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
#notes-author-filter {
min-width: min(220px, 100%);
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--fg);
font: inherit;
font-size: 0.9rem;
line-height: 1.2;
padding: 0.45rem 0.65rem;
}
#notes-author-filter:focus {
outline: 2px solid color-mix(in oklab, var(--accent) 45%, transparent);
outline-offset: 2px;
}
#notes-wall {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
margin: 2.5rem 0;
align-items: start;
}
/* =========================
NOTE CARD
========================= */
.note {
position: relative;
padding: 1rem 1rem 0.9rem;
background: var(--surface);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.08),
0 12px 24px rgba(0, 0, 0, 0.06);
transform: rotate(var(--note-tilt, 0deg));
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
background-color 0.2s ease,
border-color 0.2s ease;
}
/* subtle randomness */
.note:nth-child(3n) { --note-tilt: -0.6deg; }
.note:nth-child(4n) { --note-tilt: 0.4deg; }
.note:nth-child(5n) { --note-tilt: -0.3deg; }
.note:hover {
transform: rotate(0deg) translateY(-4px);
box-shadow:
0 6px 14px rgba(0, 0, 0, 0.12),
0 18px 36px rgba(0, 0, 0, 0.10);
}
/* =========================
PIN
========================= */
.note::before {
content: "";
position: absolute;
top: 0.6rem;
left: 50%;
width: 10px;
height: 10px;
background: #c33;
border-radius: 50%;
transform: translateX(-50%);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.3);
opacity: 0.85;
}
/* =========================
META
========================= */
.note-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.7rem;
margin-bottom: 0.5rem;
color: var(--muted);
}
.note-author {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.note-meta time {
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
/* =========================
CONTENT
========================= */
.note-content {
font-size: 0.95rem;
line-height: 1.45;
white-space: pre-wrap;
color: var(--fg);
}
/* =========================
MOBILE TWEAKS
========================= */
@media (max-width: 600px) {
#notes-wall {
grid-template-columns: 1fr;
}
#notes-tools {
align-items: stretch;
flex-direction: column;
}
.note {
transform: none;
}
.note::before {
left: 0.75rem;
transform: none;
}
}
/* =========================
NOTES FORM
========================= */
#notes-form-wrapper {
margin-top: 3rem;
display: flex;
justify-content: center;
}
#notes-form {
width: 100%;
max-width: 420px;
background: var(--surface);
color: var(--fg);
border: 1px solid var(--border);
padding: 1.25rem 1.25rem 1.1rem;
border-radius: 10px;
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.08),
0 14px 30px rgba(0, 0, 0, 0.06);
position: relative;
}
/* pin */
#notes-form::before {
content: "";
position: absolute;
top: 0.6rem;
left: 50%;
width: 12px;
height: 12px;
background: #c33;
border-radius: 50%;
transform: translateX(-50%);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.35);
}
/* =========================
LABELS
========================= */
#notes-form label {
display: block;
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.25rem;
}
/* =========================
INPUTS
========================= */
#notes-form input,
#notes-form textarea {
width: 100%;
border: none;
background: transparent;
color: var(--fg);
font-family: inherit;
font-size: 0.95rem;
line-height: 1.45;
padding: 0.3rem 0;
margin-bottom: 0.9rem;
border-bottom: 1px solid var(--border);
}
#notes-form textarea {
resize: vertical;
min-height: 4.5rem;
}
/* focus state */
#notes-form input:focus,
#notes-form textarea:focus {
outline: none;
border-bottom-color: var(--accent);
}
/* placeholder */
#notes-form ::placeholder {
color: var(--muted);
}
/* =========================
SUBMIT BUTTON
========================= */
#notes-form button {
margin-top: 0.5rem;
padding: 0.35rem 0.9rem;
font-family: inherit;
font-size: 0.8rem;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 999px;
border: none;
background: var(--fg);
color: var(--bg);
cursor: pointer;
float: right;
}
#notes-form button:hover {
background: var(--accent);
color: var(--surface);
}
#notes-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* =========================
MOBILE
========================= */
@media (max-width: 600px) {
#notes-form {
max-width: 100%;
}
}

View File

@@ -0,0 +1,503 @@
#updated{
font-size: .76rem;
color: var(--muted);
margin: .45rem 0 0;
text-align: right;
}
.sidenote,
.marginnote{
float: right;
width: var(--margin);
margin-right: calc(-1 * ((100vi - var(--content)) / 2));
padding-right: var(--gutter);
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
margin-top: .3rem;
margin-bottom: .6rem;
font-size: .95rem;
line-height: 1.35;
color: var(--note-color);
background: var(--note-bg);
box-sizing: border-box;
}
.sidenote,
.marginnote{
display: block;
clear: right;
}
.sidenote + .sidenote,
.sidenote + .marginnote,
.marginnote + .sidenote,
.marginnote + .marginnote{
margin-top: .4rem;
}
.footnote-sidenote::before{
content: attr(data-fn) " ";
font-size: 0.85em;
vertical-align: super;
margin-right: 0.2rem;
opacity: 0.85;
}
body{ counter-reset: sidenote-counter; }
.sidenote-number:after{
counter-increment: sidenote-counter;
content: counter(sidenote-counter);
font-size: .85em;
vertical-align: super;
margin-left: .15rem;
}
.margin-toggle{
position: absolute;
opacity: 0;
pointer-events: none;
}
.fullwidth{
display: block;
position: relative;
left: 50%;
transform: translateX(-50%);
height: auto;
margin: 1.25rem 0 1.75rem;
width: 100%;
max-width: min(
calc(var(--content) + var(--bleed) * 2),
var(--fullwidth-cap),
calc(100vw - 2 * var(--body-pad))
);
}
.figure .fullwidth{ width: inherit; }
.figure figcaption{
text-align: center; font-size: .95rem; color: #666; margin-top: .4rem;
}
.post-date{
color: var(--muted);
font-size: .86em;
margin-left: 8px;
}
.post-tag{
float: right;
display: inline-block;
background-color: var(--chip-bg);
color: var(--chip-fg);
border: 1px solid color-mix(in oklab, var(--border) 78%, transparent);
border-radius: 999px;
padding: 0.08rem 0.5rem;
margin-left: 6px;
font-size: 0.8em;
line-height: 1.7;
}
.filetags {
margin-top: .5rem;
display: flex;
flex-wrap: wrap;
gap: .3rem .4rem;
font-size: .9rem;
color: #666;
}
.filetags .tag {
display: inline-block;
padding: .1rem .45rem;
border-radius: 999px;
background: var(--chip-bg);
color: var(--chip-fg);
border: 1px solid color-mix(in oklab, var(--border) 78%, transparent);
line-height: 1.6;
}
.figure figcaption{ color: var(--muted); }
.org-src-container{
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: none;
overflow: hidden;
background: var(--code-bg);
}
:not(pre) > code{
border: 1px solid var(--border);
background-color: var(--code-bg);
color: var(--fg);
border-radius: 5px;
padding: 0.05rem 0.28rem;
}
.theme-toggle {
border: 1px solid var(--border);
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
color: var(--fg);
min-height: 2.25rem;
padding: 0 0.75rem;
border-radius: 7px;
cursor: pointer;
font: inherit;
font-size: 0.9rem;
font-weight: 750;
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
}
.theme-toggle:hover,
.theme-toggle:focus-visible {
background-color: color-mix(in oklab, var(--accent) 12%, transparent);
color: var(--heading);
border-color: color-mix(in oklab, var(--accent) 42%, var(--border));
outline: 0;
}
.web-logo {
position: absolute;
top: 0.75rem;
left: 1rem;
z-index: 1000;
width: 10%;
padding: .25rem;
border-radius: 6px;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
.sidenote .mn-fig,
.marginnote .mn-fig{
margin: 0;
}
.sidenote .mn-img,
.marginnote .mn-img{
display: block;
max-width: 100%;
height: auto;
border: 1px solid var(--border, #d7d7d7);
border-radius: 6px;
background: var(--bg);
}
.sidenote .mn-fig figcaption,
.marginnote .mn-fig figcaption{
margin-top: .35rem;
font-size: .85rem;
color: var(--muted, #666);
line-height: 1.35;
}
.sidenote img {
margin-top: 0.35rem;
display: block;
}
.countdown-wrap {
display: flex;
justify-content: center;
align-items: center;
padding: 1em 0;
font-family: system-ui, sans-serif;
font-size: 1.2rem;
color: var(--fg, #222);
background: var(--bg-alt, transparent);
text-align: center;
}
time.countdown {
font-weight: 600;
color: var(--accent, #2a7fff);
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
padding: 0.25em 0.6em;
border-radius: 0.5em;
background: color-mix(in srgb, var(--accent, #2a7fff) 10%, transparent);
box-shadow: 0 0 8px rgba(0,0,0,0.1);
transition: color 0.3s ease, background 0.3s ease;
}
time.countdown.expired {
color: #999;
background: none;
font-weight: 500;
text-decoration: line-through;
}
/* SEARCH */
#search-input {
width: 100%;
min-height: 2.25rem;
padding: 0.35rem 0.7rem;
font-size: 0.9rem;
border: 1px solid var(--border);
border-radius: 7px;
font-family: var(--font-body);
font-weight: 450;
background-color: var(--surface);
color: var(--fg);
}
#search-btn {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
min-width: 2.25rem;
height: 2.25rem;
padding: 0 0.7rem;
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
border: 1px solid var(--border);
border-radius: 7px;
color: var(--fg);
cursor: pointer;
font-size: 0.95rem;
line-height: 1;
}
#search-input:focus,
#search-btn:focus-visible {
outline: 0;
border-color: color-mix(in oklab, var(--accent) 55%, var(--border));
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 16%, transparent);
}
@media (max-width: 700px) {
#search-input {
display: none;
}
#search-btn {
font-size: 1rem;
}
}
.site-actions {
position: relative;
}
.search-inline-panel {
position: absolute;
top: calc(100% + 0.55rem);
right: 0;
width: min(38rem, calc(100vw - 2rem));
max-height: min(64vh, 34rem);
display: none;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
background: color-mix(in oklab, var(--surface) 94%, var(--bg));
border: 1px solid color-mix(in oklab, var(--border) 78%, var(--fg));
border-radius: 8px;
box-shadow:
0 18px 56px rgba(0, 0, 0, 0.22),
0 2px 12px rgba(0, 0, 0, 0.12);
z-index: 1000;
}
.site-actions.search-inline-open .search-inline-panel {
display: grid;
}
.search-pane-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 1rem 0.55rem;
border-bottom: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
}
.search-pane-meta h2 {
margin: 0;
color: var(--fg);
font-size: 0.95rem;
line-height: 1.2;
}
#search-pane-count {
color: var(--muted);
font-size: 0.78rem;
line-height: 1.35;
}
.search-pane-results {
min-height: 8rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.45rem;
}
.search-result {
display: grid;
gap: 0.26rem;
padding: 0.7rem 0.8rem;
border: 1px solid transparent;
border-radius: 7px;
color: inherit;
cursor: pointer;
text-decoration: none;
}
.search-result:hover,
.search-result.active {
background: var(--accent-bg);
border-color: color-mix(in oklab, var(--accent) 28%, var(--border));
text-decoration: none;
}
.search-result-title {
color: var(--fg);
font-weight: 600;
font-size: 0.95rem;
line-height: 1.25;
}
.search-result-preview {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.35;
}
.search-result-path {
color: color-mix(in oklab, var(--muted) 82%, var(--accent));
font-size: 0.72rem;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-empty {
display: grid;
min-height: 9rem;
place-items: center;
padding: 1rem;
color: var(--muted);
text-align: center;
font-size: 0.9rem;
}
.search-pane-help {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
padding: 0.65rem 0.8rem;
border-top: 1px solid var(--border);
color: var(--muted);
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
font-size: 0.72rem;
}
.search-pane-help span {
padding: 0.22rem 0.45rem;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
}
mark {
background: rgba(255, 230, 150, 0.8);
color: inherit;
padding: 0 0.15em;
border-radius: 3px;
}
.search-result.active mark {
background: rgba(255, 230, 150, 0.9);
}
@media (max-width: 700px) {
.site-actions.search-inline-open {
flex-wrap: wrap;
}
.site-actions.search-inline-open .site-search {
display: block;
order: 1;
flex: 1 1 calc(100% - 2.75rem);
min-width: 0;
}
.site-actions.search-inline-open #search-input {
display: block;
}
.site-actions.search-inline-open #search-btn,
.site-actions.search-inline-open .theme-toggle {
order: 2;
}
.search-inline-panel {
position: static;
order: 3;
flex: 1 1 100%;
width: 100%;
max-height: min(58vh, 30rem);
}
.search-pane-results {
min-height: 7rem;
}
}
@media (max-width: 560px) {
.search-pane-meta {
align-items: flex-start;
flex-direction: column;
gap: 0.2rem;
}
}
/* Table of contents */
#table-of-contents{
float: left;
width: var(--margin);
margin-left: calc(-1 * ((100vi - var(--content)) / 2));
padding-left: var(--gutter);
box-sizing: border-box;
position: sticky;
top: 5.5rem;
max-height: calc(100vh - 6rem);
overflow: auto;
font-size: .95rem;
border-right: 1px solid var(--border, #d7d7d7);
padding-top: .25rem;
}
#table-of-contents > h2{
margin: 0 0 .5rem;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: .02em;
color: var(--muted, #666);
}
#text-table-of-contents ul{
list-style: none;
margin: 0;
padding-left: 0;
}
#text-table-of-contents li{
margin: .25rem 0;
}
#text-table-of-contents a{
text-decoration: none;
}
#text-table-of-contents a:hover{
text-decoration: underline;
}
#text-table-of-contents ul ul { margin-left: .75rem; opacity: .9; }
h2[id], h3[id], h4[id] { scroll-margin-top: 6.5rem; }
#text-table-of-contents a.is-active{
text-decoration: underline;
background-color: var(--active-toc);
}

View File

@@ -0,0 +1,95 @@
.org-comment { color: #b22222; }
.org-string { color: #8b2252; }
.org-keyword { color: #a020f0; }
.org-builtin { color: #483d8b; }
.org-constant { color: #008b8b; }
.org-function-name { color: #0000ff; }
.org-variable-name { color: sienna; }
.org-type { color: #228b22; }
.org-preprocessor { color: #483d8b; }
.org-doc { color: #8b2252; }
.org-warning { color: #ff8c00; font-weight: 700; }
:root[data-theme="dark-academia"] {
--syntax-comment: #a97861;
--syntax-string: #c99572;
--syntax-keyword: #d6b566;
--syntax-builtin: #b99b63;
--syntax-constant: #7fb092;
--syntax-function: #e8cf91;
--syntax-variable: #d0a35c;
--syntax-type: #8fbf9f;
--syntax-warning: #d58a55;
}
:root[data-theme="dark-academia"] .org-comment { color: var(--syntax-comment); }
:root[data-theme="dark-academia"] .org-string { color: var(--syntax-string); }
:root[data-theme="dark-academia"] .org-keyword { color: var(--syntax-keyword); }
:root[data-theme="dark-academia"] .org-builtin { color: var(--syntax-builtin); }
:root[data-theme="dark-academia"] .org-constant { color: var(--syntax-constant); }
:root[data-theme="dark-academia"] .org-function-name { color: var(--syntax-function); }
:root[data-theme="dark-academia"] .org-variable-name { color: var(--syntax-variable); }
:root[data-theme="dark-academia"] .org-type { color: var(--syntax-type); }
:root[data-theme="dark-academia"] .org-preprocessor { color: var(--syntax-builtin); }
:root[data-theme="dark-academia"] .org-doc { color: var(--syntax-string); }
:root[data-theme="dark-academia"] .org-warning { color: var(--syntax-warning); }
:not(pre) > code{
padding: 2px 5px; margin: 0 1px;
border: 1px solid var(--border); border-radius: 3px;
background-clip: padding-box;
color: var(--fg); font-size: 80%;
}
pre {
background-color: var(--bg);
}
.org-src-container{
border: 1px solid var(--border);
box-shadow: 3px 3px 3px color-mix(in oklab, #000 8%, transparent);
font-family: Lucida Console, monospace;
font-size: 80%;
margin: 1em auto;
padding: .1em .5em;
position: relative;
}
.org-src-container > pre{ overflow: auto; }
.org-src-container > pre:before{
display: block; position: absolute; top: 0; right: 0;
background-color: var(--surface-soft); color: var(--fg);
padding: 0 .5em; border-bottom-left-radius: 8px; border: 0;
font-size: 80%;
}
.org-src-container > pre.src-bash:before { content: "bash"; }
.org-src-container > pre.src-sh:before { content: "sh"; }
.org-src-container > pre.src-shell:before { content: "shell"; }
.org-src-container > pre.src-python:before { content: "Python"; }
.org-src-container > pre.src-emacs-lisp:before { content: "Emacs Lisp"; }
.org-src-container > pre.src-js:before,
.org-src-container > pre.src-javascript:before { content: "Javascript"; }
.org-src-container > pre.src-typescript:before { content: "Typescript"; }
.org-src-container > pre.src-html:before { content: "HTML"; }
.org-src-container > pre.src-css:before { content: "CSS"; }
.org-src-container > pre.src-c:before { content: "C"; }
.org-src-container > pre.src-cpp:before { content: "C++"; }
.org-src-container > pre.src-java:before { content: "Java"; }
.org-src-container > pre.src-rust:before { content: "Rust"; }
.org-src-container > pre.src-R:before { content: "R"; }
.copy-btn{
position: absolute; top: .4em; right: .4em;
background-color: var(--code-bg); color: var(--heading);
border: 1px solid var(--heading); border-radius: 4px;
padding: .2em .6em; font-size: .8rem; cursor: pointer; z-index: 10;
transition: background-color .3s;
}
.copy-btn:hover{ background-color: var(--heading); color: var(--code-bg); }
pre.src::before{ content: none !important; }
pre.src{
padding: 1.5em 1em 1em;
font-family: "Fira Mono","Courier New",monospace;
font-size: .9rem; line-height: 1.4;
overflow-x: auto; white-space: pre-wrap;
}

View File

@@ -0,0 +1,289 @@
.hidden-detail-root {
position: relative;
z-index: 20;
}
.hidden-whisper {
display: inline-block;
color: var(--muted);
cursor: help;
text-decoration: underline dotted color-mix(in oklab, var(--muted) 55%, transparent);
text-underline-offset: 0.22em;
}
.hidden-whisper::selection,
.hidden-greeting::selection,
.hidden-footer-note::selection {
background: color-mix(in oklab, var(--accent) 32%, transparent);
}
.hidden-footer-note {
max-width: 58ch;
margin: 0.45rem auto 0;
color: var(--muted);
font-size: 0.88rem;
text-align: center;
opacity: 0.82;
}
.hidden-toast {
position: fixed;
right: 1rem;
bottom: 1rem;
max-width: min(23rem, calc(100vw - 2rem));
padding: 0.8rem 0.95rem;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 92%, var(--bg) 8%);
color: var(--fg);
box-shadow: 0 16px 40px color-mix(in oklab, #000 18%, transparent);
font-size: 0.92rem;
line-height: 1.45;
opacity: 0;
transform: translateY(0.7rem);
pointer-events: none;
transition: opacity 220ms ease, transform 220ms ease;
}
.hidden-toast.is-visible {
opacity: 1;
transform: translateY(0);
}
.hidden-message-text {
min-width: 0;
}
.hidden-layer-message strong {
display: inline-flex;
align-items: center;
gap: 0.55rem;
}
.hidden-corner-object {
position: fixed;
left: 0.65rem;
bottom: 0.65rem;
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 1px solid color-mix(in oklab, var(--border) 76%, transparent);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 84%, transparent);
color: var(--muted);
font: 1rem/1 monospace;
cursor: pointer;
opacity: 0.44;
transition: opacity 160ms ease, transform 160ms ease, color 160ms ease;
z-index: 21;
}
.hidden-corner-object:hover,
.hidden-corner-object:focus-visible {
opacity: 1;
color: var(--fg);
transform: translateY(-2px);
}
.hidden-greeting {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.9rem;
}
.hidden-drift-note {
position: fixed;
top: 20%;
left: 50%;
max-width: min(28rem, calc(100vw - 2rem));
padding: 1rem 1.1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--fg);
box-shadow: 0 18px 52px color-mix(in oklab, #000 18%, transparent);
transform: translate(-50%, -0.5rem);
z-index: 40;
}
.hidden-drift-note pre {
margin: 0.75rem 0 0;
white-space: pre;
overflow: auto;
color: var(--muted);
}
.hidden-drift-note button {
margin-top: 0.8rem;
}
.hidden-object-rail {
position: fixed;
left: 50%;
bottom: 0.35rem;
display: flex;
gap: 0.3rem;
transform: translateX(-50%);
opacity: 0.18;
transition: opacity 180ms ease;
z-index: 20;
}
.hidden-object-rail:hover,
.hidden-object-rail:focus-within {
opacity: 0.94;
}
.hidden-keepsake {
width: 1.35rem;
height: 1.35rem;
border: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
border-radius: 50%;
background: color-mix(in oklab, var(--surface) 78%, transparent);
color: var(--muted);
font: 0.8rem/1 monospace;
cursor: pointer;
}
.hidden-keepsake:hover,
.hidden-keepsake:focus-visible {
color: var(--fg);
background: var(--surface);
}
.hidden-weather-window {
position: fixed;
right: 0.7rem;
top: 7.5rem;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 84%, transparent);
color: var(--muted);
font-size: 0.72rem;
padding: 0.25rem 0.45rem;
opacity: 0.24;
z-index: 20;
}
.hidden-weather-window:hover,
.hidden-weather-window:focus-visible {
opacity: 1;
color: var(--fg);
}
.hidden-oldweb-stamp {
position: fixed;
right: 0.75rem;
bottom: 4.2rem;
max-width: 12rem;
padding: 0.25rem 0.4rem;
border: 1px dashed var(--border);
background: var(--surface);
color: var(--muted);
font: 0.72rem/1.2 "Times New Roman", serif;
text-align: center;
opacity: 0.34;
cursor: help;
z-index: 20;
}
.hidden-oldweb-stamp:hover {
opacity: 1;
}
.hidden-guestbook-line {
color: var(--muted);
font-size: 0.86rem;
font-family: "Times New Roman", serif;
opacity: 0.78;
}
.hidden-hover-memory {
position: relative;
}
.hidden-hover-memory:hover::after {
content: attr(data-hidden-memory);
position: absolute;
left: 0;
top: 100%;
width: min(28rem, 80vw);
margin-top: 0.25rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--muted);
font-size: 0.78rem;
font-family: 'Noto', system-ui, sans-serif;
font-weight: 400;
line-height: 1.35;
z-index: 30;
box-shadow: 0 12px 28px color-mix(in oklab, #000 12%, transparent);
}
body.hidden-home-takeover .db-root {
filter: saturate(0.9) contrast(1.03);
}
body.hidden-home-takeover .db-hero::before {
content: "under construction, emotionally";
display: inline-block;
margin-bottom: 0.5rem;
color: var(--muted);
font: 0.82rem "Times New Roman", serif;
}
.hidden-terminal {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.hidden-terminal input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--code-bg);
color: var(--fg);
padding: 0.65rem;
font: inherit;
}
.hidden-terminal-log {
min-height: 10rem;
white-space: pre-wrap;
}
.hidden-secret-link {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
}
body.hidden-late-night {
--hidden-night-glow: color-mix(in oklab, var(--accent) 9%, transparent);
}
body.hidden-late-night::after {
content: "";
position: fixed;
inset: auto 0 0;
height: 18vh;
pointer-events: none;
background: linear-gradient(transparent, var(--hidden-night-glow));
opacity: 0.32;
}
@media (max-width: 760px) {
.hidden-toast {
right: 0.75rem;
bottom: 0.75rem;
}
.hidden-corner-object {
width: 1.75rem;
height: 1.75rem;
}
}

327
assets/styles/hidden-details.css Normal file → Executable file
View File

@@ -1,326 +1 @@
.hidden-detail-root { @import url("./features/hidden-details.css");
position: relative;
z-index: 20;
}
.hidden-whisper {
display: inline-block;
color: var(--muted);
cursor: help;
text-decoration: underline dotted color-mix(in oklab, var(--muted) 55%, transparent);
text-underline-offset: 0.22em;
}
.hidden-whisper::selection,
.hidden-greeting::selection,
.hidden-footer-note::selection {
background: color-mix(in oklab, var(--accent) 32%, transparent);
}
.hidden-footer-note {
max-width: 58ch;
margin: 0.45rem auto 0;
color: var(--muted);
font-size: 0.88rem;
text-align: center;
opacity: 0.82;
}
.hidden-toast {
position: fixed;
right: 1rem;
bottom: 1rem;
max-width: min(23rem, calc(100vw - 2rem));
padding: 0.8rem 0.95rem;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 92%, var(--bg) 8%);
color: var(--fg);
box-shadow: 0 16px 40px color-mix(in oklab, #000 18%, transparent);
font-size: 0.92rem;
line-height: 1.45;
opacity: 0;
transform: translateY(0.7rem);
pointer-events: none;
transition: opacity 220ms ease, transform 220ms ease;
}
.hidden-toast.is-visible {
opacity: 1;
transform: translateY(0);
}
.has-hidden-avatar {
display: inline-flex;
align-items: center;
gap: 0.55rem;
}
.hidden-toast.has-hidden-avatar,
.hidden-drift-message.has-hidden-avatar {
display: flex;
}
.hidden-footer-note.has-hidden-avatar,
.hidden-greeting.has-hidden-avatar,
.hidden-guestbook-line.has-hidden-avatar {
display: flex;
justify-content: center;
}
.hidden-message-avatar {
width: 2.1rem;
height: 2.1rem;
flex: 0 0 2.1rem;
border-radius: 50%;
object-fit: cover;
border: 1px solid color-mix(in oklab, var(--hidden-avatar-glow, var(--accent)) 62%, var(--border));
box-shadow: 0 0 18px color-mix(in oklab, var(--hidden-avatar-glow, var(--accent)) 34%, transparent);
background: color-mix(in oklab, var(--surface) 82%, transparent);
}
.hidden-message-text {
min-width: 0;
}
.hidden-layer-message strong {
display: inline-flex;
align-items: center;
gap: 0.55rem;
}
.hidden-terminal-log .has-hidden-avatar {
align-items: flex-start;
}
.hidden-terminal-log .hidden-message-avatar {
margin-top: 0.1rem;
}
.hidden-corner-object {
position: fixed;
left: 0.65rem;
bottom: 0.65rem;
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 1px solid color-mix(in oklab, var(--border) 76%, transparent);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 84%, transparent);
color: var(--muted);
font: 1rem/1 monospace;
cursor: pointer;
opacity: 0.44;
transition: opacity 160ms ease, transform 160ms ease, color 160ms ease;
z-index: 21;
}
.hidden-corner-object:hover,
.hidden-corner-object:focus-visible {
opacity: 1;
color: var(--fg);
transform: translateY(-2px);
}
.hidden-greeting {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.9rem;
}
.hidden-drift-note {
position: fixed;
top: 20%;
left: 50%;
max-width: min(28rem, calc(100vw - 2rem));
padding: 1rem 1.1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--fg);
box-shadow: 0 18px 52px color-mix(in oklab, #000 18%, transparent);
transform: translate(-50%, -0.5rem);
z-index: 40;
}
.hidden-drift-note pre {
margin: 0.75rem 0 0;
white-space: pre;
overflow: auto;
color: var(--muted);
}
.hidden-drift-note button {
margin-top: 0.8rem;
}
.hidden-object-rail {
position: fixed;
left: 50%;
bottom: 0.35rem;
display: flex;
gap: 0.3rem;
transform: translateX(-50%);
opacity: 0.18;
transition: opacity 180ms ease;
z-index: 20;
}
.hidden-object-rail:hover,
.hidden-object-rail:focus-within {
opacity: 0.94;
}
.hidden-keepsake {
width: 1.35rem;
height: 1.35rem;
border: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
border-radius: 50%;
background: color-mix(in oklab, var(--surface) 78%, transparent);
color: var(--muted);
font: 0.8rem/1 monospace;
cursor: pointer;
}
.hidden-keepsake:hover,
.hidden-keepsake:focus-visible {
color: var(--fg);
background: var(--surface);
}
.hidden-weather-window {
position: fixed;
right: 0.7rem;
top: 7.5rem;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 84%, transparent);
color: var(--muted);
font-size: 0.72rem;
padding: 0.25rem 0.45rem;
opacity: 0.24;
z-index: 20;
}
.hidden-weather-window:hover,
.hidden-weather-window:focus-visible {
opacity: 1;
color: var(--fg);
}
.hidden-oldweb-stamp {
position: fixed;
right: 0.75rem;
bottom: 4.2rem;
max-width: 12rem;
padding: 0.25rem 0.4rem;
border: 1px dashed var(--border);
background: var(--surface);
color: var(--muted);
font: 0.72rem/1.2 "Times New Roman", serif;
text-align: center;
opacity: 0.34;
cursor: help;
z-index: 20;
}
.hidden-oldweb-stamp:hover {
opacity: 1;
}
.hidden-guestbook-line {
color: var(--muted);
font-size: 0.86rem;
font-family: "Times New Roman", serif;
opacity: 0.78;
}
.hidden-hover-memory {
position: relative;
}
.hidden-hover-memory:hover::after {
content: attr(data-hidden-memory);
position: absolute;
left: 0;
top: 100%;
width: min(28rem, 80vw);
margin-top: 0.25rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--muted);
font-size: 0.78rem;
font-family: 'Noto', system-ui, sans-serif;
font-weight: 400;
line-height: 1.35;
z-index: 30;
box-shadow: 0 12px 28px color-mix(in oklab, #000 12%, transparent);
}
body.hidden-home-takeover .db-root {
filter: saturate(0.9) contrast(1.03);
}
body.hidden-home-takeover .db-hero::before {
content: "under construction, emotionally";
display: inline-block;
margin-bottom: 0.5rem;
color: var(--muted);
font: 0.82rem "Times New Roman", serif;
}
.hidden-terminal {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.hidden-terminal input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--code-bg);
color: var(--fg);
padding: 0.65rem;
font: inherit;
}
.hidden-terminal-log {
min-height: 10rem;
white-space: pre-wrap;
}
.hidden-secret-link {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
}
body.hidden-late-night {
--hidden-night-glow: color-mix(in oklab, var(--accent) 9%, transparent);
}
body.hidden-late-night::after {
content: "";
position: fixed;
inset: auto 0 0;
height: 18vh;
pointer-events: none;
background: linear-gradient(transparent, var(--hidden-night-glow));
opacity: 0.32;
}
@media (max-width: 760px) {
.hidden-toast {
right: 0.75rem;
bottom: 0.75rem;
}
.hidden-corner-object {
width: 1.75rem;
height: 1.75rem;
}
}

View File

@@ -1,251 +1 @@
#kanban-board { @import url("./components/kanban.css");
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.kanban-column {
background: #f8f9fa;
border-radius: 8px;
padding: 0.6rem;
display: flex;
flex-direction: column;
}
.kanban-column h3 {
text-align: center;
font-size: 0.9rem;
margin: 0.4rem 0 0.6rem;
font-weight: 600;
}
.kanban-list {
flex-grow: 1;
min-height: 3rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kanban-card {
background: var(--bg);
border-radius: 6px;
padding: 0.5rem 0.6rem;
font-size: 0.85rem;
cursor: grab;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
color: var(--fg);
}
.kanban-card.dragging {
opacity: 0.5;
cursor: grabbing;
}
.kanban-column[data-state="completed"] {
background: var(--kb-bg-completed);
}
.kanban-column[data-state="manager_review"] {
background: var(--kb-bg-manager-review);
}
.kanban-column[data-state="in_progress"] {
background: var(--kb-bg-in-progress);
}
.kanban-column[data-state="not_started"] {
background: var(--kb-bg-not-started);
}
.kanban-column[data-state="comments"] {
background: var(--kb-bg-comments);
}
#mobile-move-panel {
display: none;
max-width: 22rem;
margin: 1.5rem auto 0 auto;
padding: 1rem;
box-sizing: border-box;
border: 1px solid var(--border-color, #ddd);
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
overflow: hidden;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
font-size: 0.9rem;
}
#mobile-move-panel * {
box-sizing: border-box;
}
#mobile-move-panel h4 {
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
font-weight: 600;
}
#mobile-move-panel label {
display: flex;
flex-direction: column;
gap: 0.35rem;
width: 100%;
}
#mobile-move-panel select,
#mobile-move-panel button {
font-size: 0.9rem;
padding: 0.4rem 0.5rem;
min-width: 0;
}
#mobile-move-panel button {
margin-top: 0.5rem;
align-self: flex-start;
}
#mobile-move-panel select {
width: 100%;
max-width: 100%;
padding: 0.45rem 2rem 0.45rem 0.6rem;
font-size: 0.9rem;
line-height: 1.2;
border: 1px solid var(--border-color, #ccc);
border-radius: 0.4rem;
background-color: var(--bg, #fff);
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background-image:
linear-gradient(45deg, transparent 50%, #666 50%),
linear-gradient(135deg, #666 50%, transparent 50%),
linear-gradient(to right, transparent, transparent);
background-position:
calc(100% - 1.2rem) 50%,
calc(100% - 0.9rem) 50%,
100% 0;
background-size:
6px 6px,
6px 6px,
2.5rem 100%;
background-repeat: no-repeat;
margin-bottom: 1em;
}
#mobile-move-panel select:focus {
outline: none;
border-color: #666;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
}
#mobile-move-panel button {
width: 100%;
padding: 0.5rem 0.6rem;
font-size: 0.9rem;
border-radius: 0.4rem;
border: 1px solid #888;
background: #f8f8f8;
cursor: pointer;
}
#mobile-move-panel button:active {
background: #eee;
}
#mobile-move-panel select:disabled {
background-color: #f3f3f3;
color: #555;
border-color: #ccc;
opacity: 1;
cursor: default;
background-image: none;
}
#progress-wrapper {
margin-bottom: 16px;
}
#progress-label {
font-size: 14px;
margin-bottom: 6px;
text-align: right;
color: #555;
}
#progress-bar {
width: 100%;
height: 10px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
}
#progress-fill {
height: 100%;
width: 0%;
background: #22c55e;
transition: width 0.3s ease;
}
#level-tabs {
display: inline-flex;
align-items: center;
gap: 0;
margin-bottom: 1rem;
border-radius: 999px;
padding: 3px;
background: var(--bg, #fff);
width: fit-content;
}
.level-tab {
border: none;
background: transparent;
padding: 0.35rem 1rem;
font-size: 0.85rem;
border-radius: 999px;
cursor: pointer;
color: var(--fg, #333);
transition:
background 0.2s ease,
color 0.2s ease,
transform 0.1s ease;
}
.level-tab:hover {
background: rgba(0,0,0,0.05);
}
.level-tab.active {
background: #333;
color: white;
font-weight: 500;
}
.level-tab:active {
transform: scale(0.97);
}

View File

@@ -0,0 +1,162 @@
.banner-header{
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.85rem;
padding: 0.7rem;
margin-top: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 88%, var(--bg) 12%);
box-shadow: 0 12px 32px color-mix(in oklab, #000 8%, transparent);
}
:root[data-theme="dark-academia"] .banner-header,
:root[data-theme="dark-academia"] .org-src-container,
:root[data-theme="dark-academia"] #notes-form,
:root[data-theme="dark-academia"] .note {
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
:root[data-theme="dark-academia"] h1,
:root[data-theme="dark-academia"] h2,
:root[data-theme="dark-academia"] h3 {
font-family: Georgia, "Times New Roman", serif;
}
.site-brand {
display: inline-flex;
align-items: center;
gap: 0.7rem;
color: var(--fg);
font-weight: 800;
white-space: nowrap;
}
.site-brand:hover {
text-decoration: none;
color: var(--heading);
}
.site-brand__text {
font-size: 0.98rem;
}
.banner-logo{
height: 42px;
width: 42px;
object-fit: cover;
border-radius: 50%;
border: 1px solid color-mix(in oklab, var(--border) 80%, transparent);
}
.site-nav {
display: flex;
align-items: center;
justify-content: center;
flex: 1 1 auto;
gap: 0.25rem;
font-weight: 700;
font-size: 0.95rem;
}
.site-nav a {
color: var(--muted);
padding: 0.45rem 0.65rem;
border-radius: 7px;
}
.site-nav a:hover,
.site-nav a:focus-visible {
color: var(--fg);
background: color-mix(in oklab, var(--fg) 7%, transparent);
text-decoration: none;
outline: 0;
}
.site-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
flex: 0 1 23rem;
}
.site-search {
display: block;
flex: 1 1 13rem;
min-width: 10rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
#preamble.status{
max-width: var(--content);
margin-left: auto;
margin-right: auto;
padding-left: var(--body-pad);
padding-right: var(--body-pad);
padding-top: 0.25rem;
box-sizing: content-box;
}
#preamble .banner-header,
#preamble #updated{
max-width: 100%;
}
footer{
color: var(--muted);
padding: 1.25rem;
margin-top: 3rem;
border-top: 1px solid var(--border);
text-align: center;
font-size: .9rem;
}
#content.content{
max-width: var(--content);
margin-left: auto !important;
margin-right: auto !important;
padding-left: var(--body-pad);
padding-right: var(--body-pad);
box-sizing: content-box;
position: relative;
padding-top: 1.5rem;
}
#content .figure,
#content img:not(.fullwidth){
max-width: 100%;
height: auto;
border-radius: 8px;
}
body.no-sidenotes {
--body-pad: 0;
--margin: 0;
--gutter: 0;
--content-min: 80ch;
--content-max: 1200px;
--content: clamp(
var(--content-min),
100vi,
var(--content-max)
);
}

View File

@@ -1,134 +1 @@
@media (max-width: 600px){ @import url("./utilities/responsive.css");
.banner-header {
align-items: stretch;
gap: 0.7rem;
margin-top: 0.75rem;
}
.site-brand {
justify-content: center;
}
.site-nav {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
width: 100%;
gap: 0.2rem;
font-size: 0.92rem;
}
.site-nav a {
padding: 0.42rem 0.55rem;
}
.site-actions {
width: 100%;
flex: 1 1 100%;
justify-content: center;
}
.site-search {
display: none;
}
#updated {
text-align: center;
}
body.no-sidenotes {
margin-left: 1em;
margin-right: 1em;
}
#mobile-move-panel {
display: block;
}
}
@media (prefers-color-scheme: dark){
:root:not([data-theme]){
--bg: #0f1115;
--fg: #e6e6e6;
--link: #8ab4ff;
--heading: #9ecbff;
--code-bg: #1a1d24;
--note-color: #c2c7cf;
--note-bg: transparent;
--border: #2a2f3a;
--chip-bg: #1f2330;
--chip-fg: #cfd3da;
--muted: #a9b0bb;
--surface: #151820;
--surface-soft: #1b202a;
--accent: #8ab4ff;
--bg-color: var(--surface);
--border-color: var(--border);
--border-muted: var(--border);
--text-muted: var(--muted);
--accent-bg: color-mix(in oklab, var(--accent) 12%, var(--surface));
}
.countdown-wrap {
color: var(--fg, #ddd);
}
time.countdown {
color: var(--accent, #7abfff);
background: color-mix(in srgb, var(--accent, #7abfff) 15%, transparent);
box-shadow: 0 0 6px rgba(255,255,255,0.05);
}
time.countdown.expired {
color: #777;
}
}
@media (max-width: 1250px){
:root {
--content-min: 0px;
--content: min(var(--content-max), calc(100vi - 2 * var(--body-pad)));
}
#preamble.status{
padding-right: var(--body-pad);
}
#content.content{
padding-right: var(--body-pad);
}
.sidenote,
.marginnote{
float: none;
clear: both;
width: auto;
display: block;
margin: 0.5rem 0 0.75rem;
padding-left: 0.75rem;
border-left: 3px solid rgba(0,0,0,.08);
margin-right: 0;
}
.fullwidth{
max-width: calc(100vw - 2 * var(--body-pad));
}
.sidenote, .marginnote{
border-left: 3px solid color-mix(in oklab, var(--fg) 12%, transparent);
}
.sidenote .mn-img,
.marginnote .mn-img{
border-color: color-mix(in oklab, var(--fg) 12%, transparent);
}
#table-of-contents{
float: none;
position: static;
width: auto;
margin: 0 0 1rem;
padding: .5rem .75rem;
border-right: 0;
border-left: 3px solid color-mix(in oklab, var(--fg) 12%, transparent);
background: color-mix(in oklab, var(--bg) 96%, var(--fg) 4%);
border-radius: 4px;
}
}

View File

@@ -1,502 +1 @@
@import url("./components/org-content.css?v=20260819-search-button");
#updated{
font-size: .76rem;
color: var(--muted);
margin: .45rem 0 0;
text-align: right;
}
.sidenote,
.marginnote{
float: right;
width: var(--margin);
margin-right: calc(-1 * ((100vi - var(--content)) / 2));
padding-right: var(--gutter);
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
margin-top: .3rem;
margin-bottom: .6rem;
font-size: .95rem;
line-height: 1.35;
color: var(--note-color);
background: var(--note-bg);
box-sizing: border-box;
}
.sidenote,
.marginnote{
display: block;
clear: right;
}
.sidenote + .sidenote,
.sidenote + .marginnote,
.marginnote + .sidenote,
.marginnote + .marginnote{
margin-top: .4rem;
}
.footnote-sidenote::before{
content: attr(data-fn) " ";
font-size: 0.85em;
vertical-align: super;
margin-right: 0.2rem;
opacity: 0.85;
}
body{ counter-reset: sidenote-counter; }
.sidenote-number:after{
counter-increment: sidenote-counter;
content: counter(sidenote-counter);
font-size: .85em;
vertical-align: super;
margin-left: .15rem;
}
.margin-toggle{
position: absolute;
opacity: 0;
pointer-events: none;
}
.fullwidth{
display: block;
position: relative;
left: 50%;
transform: translateX(-50%);
height: auto;
margin: 1.25rem 0 1.75rem;
width: 100%;
max-width: min(
calc(var(--content) + var(--bleed) * 2),
var(--fullwidth-cap),
calc(100vw - 2 * var(--body-pad))
);
}
.figure .fullwidth{ width: inherit; }
.figure figcaption{
text-align: center; font-size: .95rem; color: #666; margin-top: .4rem;
}
.post-date{
color: var(--muted);
font-size: .86em;
margin-left: 8px;
}
.post-tag{
float: right;
display: inline-block;
background-color: var(--chip-bg);
color: var(--chip-fg);
border: 1px solid color-mix(in oklab, var(--border) 78%, transparent);
border-radius: 999px;
padding: 0.08rem 0.5rem;
margin-left: 6px;
font-size: 0.8em;
line-height: 1.7;
}
.filetags {
margin-top: .5rem;
display: flex;
flex-wrap: wrap;
gap: .3rem .4rem;
font-size: .9rem;
color: #666;
}
.filetags .tag {
display: inline-block;
padding: .1rem .45rem;
border-radius: 999px;
background: var(--chip-bg);
color: var(--chip-fg);
border: 1px solid color-mix(in oklab, var(--border) 78%, transparent);
line-height: 1.6;
}
.figure figcaption{ color: var(--muted); }
.org-src-container{
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: none;
overflow: hidden;
background: var(--code-bg);
}
:not(pre) > code{
border: 1px solid var(--border);
background-color: var(--code-bg);
color: var(--fg);
border-radius: 5px;
padding: 0.05rem 0.28rem;
}
.theme-toggle {
border: 1px solid var(--border);
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
color: var(--fg);
min-height: 2.25rem;
padding: 0 0.75rem;
border-radius: 7px;
cursor: pointer;
font: inherit;
font-size: 0.9rem;
font-weight: 750;
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
}
.theme-toggle:hover,
.theme-toggle:focus-visible {
background-color: color-mix(in oklab, var(--accent) 12%, transparent);
color: var(--heading);
border-color: color-mix(in oklab, var(--accent) 42%, var(--border));
outline: 0;
}
.web-logo {
position: absolute;
top: 0.75rem;
left: 1rem;
z-index: 1000;
width: 10%;
padding: .25rem;
border-radius: 6px;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
.sidenote .mn-fig,
.marginnote .mn-fig{
margin: 0;
}
.sidenote .mn-img,
.marginnote .mn-img{
display: block;
max-width: 100%;
height: auto;
border: 1px solid var(--border, #d7d7d7);
border-radius: 6px;
background: var(--bg);
}
.sidenote .mn-fig figcaption,
.marginnote .mn-fig figcaption{
margin-top: .35rem;
font-size: .85rem;
color: var(--muted, #666);
line-height: 1.35;
}
.sidenote img {
margin-top: 0.35rem;
display: block;
}
.countdown-wrap {
display: flex;
justify-content: center;
align-items: center;
padding: 1em 0;
font-family: system-ui, sans-serif;
font-size: 1.2rem;
color: var(--fg, #222);
background: var(--bg-alt, transparent);
text-align: center;
}
time.countdown {
font-weight: 600;
color: var(--accent, #2a7fff);
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
padding: 0.25em 0.6em;
border-radius: 0.5em;
background: color-mix(in srgb, var(--accent, #2a7fff) 10%, transparent);
box-shadow: 0 0 8px rgba(0,0,0,0.1);
transition: color 0.3s ease, background 0.3s ease;
}
time.countdown.expired {
color: #999;
background: none;
font-weight: 500;
text-decoration: line-through;
}
/* SEARCH */
#search-input {
width: 100%;
min-height: 2.25rem;
padding: 0.35rem 0.7rem;
font-size: 0.9rem;
border: 1px solid var(--border);
border-radius: 7px;
font-family: var(--font-body);
font-weight: 450;
background-color: var(--surface);
color: var(--fg);
}
#search-btn {
display: inline-grid;
place-items: center;
flex: 0 0 2.25rem;
width: 2.25rem;
height: 2.25rem;
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
border: 1px solid var(--border);
border-radius: 7px;
color: var(--fg);
cursor: pointer;
font-size: 0.95rem;
line-height: 1;
}
#search-input:focus,
#search-btn:focus-visible {
outline: 0;
border-color: color-mix(in oklab, var(--accent) 55%, var(--border));
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 16%, transparent);
}
@media (max-width: 700px) {
#search-input {
display: none;
}
#search-btn {
font-size: 1rem;
}
}
.site-actions {
position: relative;
}
.search-inline-panel {
position: absolute;
top: calc(100% + 0.55rem);
right: 0;
width: min(38rem, calc(100vw - 2rem));
max-height: min(64vh, 34rem);
display: none;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
background: color-mix(in oklab, var(--surface) 94%, var(--bg));
border: 1px solid color-mix(in oklab, var(--border) 78%, var(--fg));
border-radius: 8px;
box-shadow:
0 18px 56px rgba(0, 0, 0, 0.22),
0 2px 12px rgba(0, 0, 0, 0.12);
z-index: 1000;
}
.site-actions.search-inline-open .search-inline-panel {
display: grid;
}
.search-pane-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 1rem 0.55rem;
border-bottom: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
}
.search-pane-meta h2 {
margin: 0;
color: var(--fg);
font-size: 0.95rem;
line-height: 1.2;
}
#search-pane-count {
color: var(--muted);
font-size: 0.78rem;
line-height: 1.35;
}
.search-pane-results {
min-height: 8rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.45rem;
}
.search-result {
display: grid;
gap: 0.26rem;
padding: 0.7rem 0.8rem;
border: 1px solid transparent;
border-radius: 7px;
color: inherit;
cursor: pointer;
text-decoration: none;
}
.search-result:hover,
.search-result.active {
background: var(--accent-bg);
border-color: color-mix(in oklab, var(--accent) 28%, var(--border));
text-decoration: none;
}
.search-result-title {
color: var(--fg);
font-weight: 600;
font-size: 0.95rem;
line-height: 1.25;
}
.search-result-preview {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.35;
}
.search-result-path {
color: color-mix(in oklab, var(--muted) 82%, var(--accent));
font-size: 0.72rem;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-empty {
display: grid;
min-height: 9rem;
place-items: center;
padding: 1rem;
color: var(--muted);
text-align: center;
font-size: 0.9rem;
}
.search-pane-help {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
padding: 0.65rem 0.8rem;
border-top: 1px solid var(--border);
color: var(--muted);
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
font-size: 0.72rem;
}
.search-pane-help span {
padding: 0.22rem 0.45rem;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
}
mark {
background: rgba(255, 230, 150, 0.8);
color: inherit;
padding: 0 0.15em;
border-radius: 3px;
}
.search-result.active mark {
background: rgba(255, 230, 150, 0.9);
}
@media (max-width: 700px) {
.site-actions.search-inline-open {
flex-wrap: wrap;
}
.site-actions.search-inline-open .site-search {
display: block;
order: 1;
flex: 1 1 calc(100% - 2.75rem);
min-width: 0;
}
.site-actions.search-inline-open #search-input {
display: block;
}
.site-actions.search-inline-open #search-btn,
.site-actions.search-inline-open .theme-toggle {
order: 2;
}
.search-inline-panel {
position: static;
order: 3;
flex: 1 1 100%;
width: 100%;
max-height: min(58vh, 30rem);
}
.search-pane-results {
min-height: 7rem;
}
}
@media (max-width: 560px) {
.search-pane-meta {
align-items: flex-start;
flex-direction: column;
gap: 0.2rem;
}
}
/* Table of contents */
#table-of-contents{
float: left;
width: var(--margin);
margin-left: calc(-1 * ((100vi - var(--content)) / 2));
padding-left: var(--gutter);
box-sizing: border-box;
position: sticky;
top: 5.5rem;
max-height: calc(100vh - 6rem);
overflow: auto;
font-size: .95rem;
border-right: 1px solid var(--border, #d7d7d7);
padding-top: .25rem;
}
#table-of-contents > h2{
margin: 0 0 .5rem;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: .02em;
color: var(--muted, #666);
}
#text-table-of-contents ul{
list-style: none;
margin: 0;
padding-left: 0;
}
#text-table-of-contents li{
margin: .25rem 0;
}
#text-table-of-contents a{
text-decoration: none;
}
#text-table-of-contents a:hover{
text-decoration: underline;
}
#text-table-of-contents ul ul { margin-left: .75rem; opacity: .9; }
h2[id], h3[id], h4[id] { scroll-margin-top: 6.5rem; }
#text-table-of-contents a.is-active{
text-decoration: underline;
background-color: var(--active-toc);
}

View File

@@ -1,95 +1 @@
.org-comment { color: #b22222; } @import url("./components/org-syntax.css");
.org-string { color: #8b2252; }
.org-keyword { color: #a020f0; }
.org-builtin { color: #483d8b; }
.org-constant { color: #008b8b; }
.org-function-name { color: #0000ff; }
.org-variable-name { color: sienna; }
.org-type { color: #228b22; }
.org-preprocessor { color: #483d8b; }
.org-doc { color: #8b2252; }
.org-warning { color: #ff8c00; font-weight: 700; }
:root[data-theme="dark-academia"] {
--syntax-comment: #a97861;
--syntax-string: #c99572;
--syntax-keyword: #d6b566;
--syntax-builtin: #b99b63;
--syntax-constant: #7fb092;
--syntax-function: #e8cf91;
--syntax-variable: #d0a35c;
--syntax-type: #8fbf9f;
--syntax-warning: #d58a55;
}
:root[data-theme="dark-academia"] .org-comment { color: var(--syntax-comment); }
:root[data-theme="dark-academia"] .org-string { color: var(--syntax-string); }
:root[data-theme="dark-academia"] .org-keyword { color: var(--syntax-keyword); }
:root[data-theme="dark-academia"] .org-builtin { color: var(--syntax-builtin); }
:root[data-theme="dark-academia"] .org-constant { color: var(--syntax-constant); }
:root[data-theme="dark-academia"] .org-function-name { color: var(--syntax-function); }
:root[data-theme="dark-academia"] .org-variable-name { color: var(--syntax-variable); }
:root[data-theme="dark-academia"] .org-type { color: var(--syntax-type); }
:root[data-theme="dark-academia"] .org-preprocessor { color: var(--syntax-builtin); }
:root[data-theme="dark-academia"] .org-doc { color: var(--syntax-string); }
:root[data-theme="dark-academia"] .org-warning { color: var(--syntax-warning); }
:not(pre) > code{
padding: 2px 5px; margin: 0 1px;
border: 1px solid var(--border); border-radius: 3px;
background-clip: padding-box;
color: var(--fg); font-size: 80%;
}
pre {
background-color: var(--bg);
}
.org-src-container{
border: 1px solid var(--border);
box-shadow: 3px 3px 3px color-mix(in oklab, #000 8%, transparent);
font-family: Lucida Console, monospace;
font-size: 80%;
margin: 1em auto;
padding: .1em .5em;
position: relative;
}
.org-src-container > pre{ overflow: auto; }
.org-src-container > pre:before{
display: block; position: absolute; top: 0; right: 0;
background-color: var(--surface-soft); color: var(--fg);
padding: 0 .5em; border-bottom-left-radius: 8px; border: 0;
font-size: 80%;
}
.org-src-container > pre.src-bash:before { content: "bash"; }
.org-src-container > pre.src-sh:before { content: "sh"; }
.org-src-container > pre.src-shell:before { content: "shell"; }
.org-src-container > pre.src-python:before { content: "Python"; }
.org-src-container > pre.src-emacs-lisp:before { content: "Emacs Lisp"; }
.org-src-container > pre.src-js:before,
.org-src-container > pre.src-javascript:before { content: "Javascript"; }
.org-src-container > pre.src-typescript:before { content: "Typescript"; }
.org-src-container > pre.src-html:before { content: "HTML"; }
.org-src-container > pre.src-css:before { content: "CSS"; }
.org-src-container > pre.src-c:before { content: "C"; }
.org-src-container > pre.src-cpp:before { content: "C++"; }
.org-src-container > pre.src-java:before { content: "Java"; }
.org-src-container > pre.src-rust:before { content: "Rust"; }
.org-src-container > pre.src-R:before { content: "R"; }
.copy-btn{
position: absolute; top: .4em; right: .4em;
background-color: var(--code-bg); color: var(--heading);
border: 1px solid var(--heading); border-radius: 4px;
padding: .2em .6em; font-size: .8rem; cursor: pointer; z-index: 10;
transition: background-color .3s;
}
.copy-btn:hover{ background-color: var(--heading); color: var(--code-bg); }
pre.src::before{ content: none !important; }
pre.src{
padding: 1.5em 1em 1em;
font-family: "Fira Mono","Courier New",monospace;
font-size: .9rem; line-height: 1.4;
overflow-x: auto; white-space: pre-wrap;
}

View File

@@ -0,0 +1,649 @@
/* Home dashboard styles. Scoped to .db-* so article pages keep their layout. */
.db-root {
--db-surface: color-mix(in oklab, var(--bg) 94%, var(--fg) 6%);
--db-surface-strong: color-mix(in oklab, var(--bg) 88%, var(--fg) 12%);
--db-line: color-mix(in oklab, var(--border) 78%, var(--fg) 22%);
--db-text-soft: color-mix(in oklab, var(--fg) 68%, var(--bg) 32%);
--db-blue: #2f6fed;
--db-green: #15835b;
--db-amber: #9d6400;
--db-orange: #c55a11;
--db-red: #b64c45;
width: 100%;
padding: 1.5rem 0 4rem;
box-sizing: border-box;
}
.db-root *,
.db-root *::before,
.db-root *::after {
box-sizing: border-box;
}
.db-kicker {
margin: 0 0 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.12em;
line-height: 1.2;
text-transform: uppercase;
}
.db-hero {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
margin-bottom: 1rem;
background: var(--db-surface);
border: 1px solid var(--db-line);
border-radius: 8px;
}
.db-hero__brand {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.db-hero__logo {
width: clamp(64px, 10vw, 96px);
height: clamp(64px, 10vw, 96px);
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--db-line);
background: var(--bg);
flex: 0 0 auto;
}
.db-hero__title {
margin: 0;
color: var(--fg);
font-size: clamp(2.4rem, 7vw, 5rem);
font-weight: 900;
line-height: 0.95;
}
.db-hero__sub {
max-width: 58ch;
margin: 0.55rem 0 0;
color: var(--db-text-soft);
font-size: 0.98rem;
line-height: 1.55;
}
.db-now {
display: grid;
align-content: center;
min-width: min(100%, 230px);
padding: 1rem;
background: color-mix(in oklab, var(--bg) 84%, var(--fg) 16%);
border: 1px solid var(--db-line);
border-radius: 8px;
}
.db-now__label {
color: var(--muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.db-now__clock {
display: block;
margin: 0.2rem 0 0.85rem;
color: var(--fg);
font-size: clamp(1.9rem, 4vw, 2.75rem);
font-variant-numeric: tabular-nums;
font-weight: 800;
line-height: 1;
}
.db-command {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 420px);
gap: 1rem;
align-items: center;
padding: 1rem 0;
}
.db-command__title {
margin: 0;
color: var(--fg);
font-size: clamp(1.2rem, 3vw, 1.85rem);
line-height: 1.15;
}
.db-command__search {
display: flex;
align-items: center;
gap: 0.6rem;
min-height: 48px;
padding: 0 0.85rem;
background: var(--db-surface);
border: 1px solid var(--db-line);
border-radius: 8px;
}
.db-command__search:focus-within {
border-color: var(--db-blue);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--db-blue) 18%, transparent);
}
.db-search-icon {
color: var(--muted);
font-size: 1.1rem;
}
.db-command__search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--fg);
font: inherit;
}
.db-command__search input::placeholder {
color: var(--muted);
}
.db-quicknav {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin: 0 0 1.25rem;
}
.db-qn-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-areas:
"icon label"
"icon meta";
column-gap: 0.7rem;
align-items: center;
min-height: 76px;
padding: 0.9rem;
color: var(--fg);
text-decoration: none;
background: var(--db-surface);
border: 1px solid var(--db-line);
border-left: 4px solid var(--db-blue);
border-radius: 8px;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.db-qn-item:hover,
.db-qn-item:focus-visible {
color: var(--fg);
text-decoration: none;
border-color: color-mix(in oklab, var(--db-blue) 55%, var(--db-line));
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.db-qn-item.is-hidden {
display: none;
}
.db-qn-item--writing,
.db-qn-item--notes,
.db-qn-item--reading { border-left-color: var(--db-blue); }
.db-qn-item--career,
.db-qn-item--wird { border-left-color: var(--db-green); }
.db-qn-item--lima,
.db-qn-item--status { border-left-color: var(--db-amber); }
.db-qn-item--system,
.db-qn-item--zone { border-left-color: var(--db-red); }
.db-qn-item--author { border-left-color: var(--db-orange); }
.db-qn-icon {
grid-area: icon;
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
color: var(--fg);
background: color-mix(in oklab, var(--fg) 8%, transparent);
border: 1px solid var(--db-line);
border-radius: 8px;
font-size: 1.05rem;
line-height: 1;
}
.db-qn-label {
grid-area: label;
min-width: 0;
overflow-wrap: anywhere;
font-size: 1rem;
font-weight: 850;
}
.db-qn-meta {
grid-area: meta;
color: var(--muted);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.db-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 1rem;
align-items: start;
}
.db-panel {
grid-column: span 4;
min-width: 0;
padding: 1rem;
background: var(--db-surface);
border: 1px solid var(--db-line);
border-radius: 8px;
}
.db-panel-stack {
grid-column: span 4;
display: grid;
gap: 1rem;
min-width: 0;
}
.db-panel-stack .db-panel {
grid-column: auto;
}
.db-panel--wide {
grid-column: span 8;
}
.db-panel--stats,
.db-panel--focus {
grid-column: span 4;
}
.db-panel--about {
grid-column: span 8;
}
.db-panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.85rem;
}
.db-panel__title {
margin: 0;
color: var(--fg);
font-size: 1.05rem;
line-height: 1.25;
}
.db-panel__more {
flex: 0 0 auto;
color: var(--link);
font-size: 0.82rem;
font-weight: 800;
text-decoration: none;
}
.db-panel__more:hover {
text-decoration: underline;
}
.db-panel__desc {
margin: 0;
color: var(--db-text-soft);
font-size: 0.92rem;
line-height: 1.6;
}
.db-feed {
display: grid;
gap: 0.15rem;
list-style: none;
margin: 0;
padding: 0;
}
.db-feed__item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.6rem;
align-items: baseline;
min-width: 0;
padding: 0.62rem 0;
margin: 0;
border-top: 1px solid color-mix(in oklab, var(--db-line) 70%, transparent);
line-height: 1.35;
}
.db-feed__item::before {
content: none !important;
}
.db-feed__dot {
width: 0.48rem;
height: 0.48rem;
margin-top: 0.25rem;
background: var(--db-green);
border-radius: 999px;
}
.db-feed__item a {
min-width: 0;
color: var(--fg);
font-size: 0.92rem;
font-weight: 700;
overflow-wrap: anywhere;
text-decoration: none;
}
.db-feed__item a:hover {
color: var(--link);
text-decoration: underline;
}
.db-feed__meta {
color: var(--muted);
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
font-weight: 700;
white-space: nowrap;
}
.db-comment-feed {
display: grid;
gap: 0;
list-style: none;
margin: 0;
padding: 0;
}
.db-comment-feed__item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.65rem;
align-items: start;
min-width: 0;
padding: 0.72rem 0;
margin: 0;
border-top: 1px solid color-mix(in oklab, var(--db-line) 70%, transparent);
}
.db-comment-feed__item::before {
content: none !important;
}
.db-comment-feed__body {
min-width: 0;
}
.db-comment-feed__top {
display: flex;
flex-wrap: wrap;
gap: 0.32rem;
align-items: baseline;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.35;
}
.db-comment-feed__top strong {
color: var(--fg);
font-size: 0.86rem;
}
.db-comment-feed__page {
color: var(--link);
font-weight: 800;
text-decoration: none;
}
a.db-comment-feed__page:hover {
text-decoration: underline;
}
.db-comment-feed__top time {
margin-left: auto;
color: var(--muted);
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
font-weight: 700;
white-space: nowrap;
}
.db-comment-feed__body p {
margin: 0.22rem 0 0;
color: var(--db-text-soft);
font-size: 0.9rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.db-comment-feed__empty {
color: var(--muted);
font-size: 0.9rem;
font-style: italic;
}
.db-stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.db-stat {
min-height: 92px;
padding: 0.8rem;
background: color-mix(in oklab, var(--bg) 82%, var(--fg) 18%);
border: 1px solid var(--db-line);
border-radius: 8px;
}
.db-stat__num {
display: block;
color: var(--fg);
font-size: clamp(1.55rem, 4vw, 2.1rem);
font-variant-numeric: tabular-nums;
font-weight: 900;
line-height: 1;
}
.db-stat__label {
display: block;
margin-top: 0.5rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.db-year-bar {
position: relative;
height: 8px;
background: color-mix(in oklab, var(--border) 75%, var(--bg) 25%);
border-radius: 999px;
}
.db-year-bar__fill {
width: 0%;
min-width: 6px;
height: 100%;
background: var(--db-green);
border-radius: 999px;
transition: width 0.45s ease;
}
.db-year-bar__label {
position: absolute;
right: 0;
bottom: calc(100% + 0.35rem);
color: var(--muted);
font-size: 0.74rem;
font-weight: 850;
}
.db-task-list,
.db-link-stack {
display: grid;
gap: 0.55rem;
}
.db-task-list a,
.db-link-stack a {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 44px;
padding: 0.65rem 0.75rem;
color: var(--fg);
background: color-mix(in oklab, var(--bg) 85%, var(--fg) 15%);
border: 1px solid var(--db-line);
border-radius: 8px;
font-weight: 750;
text-decoration: none;
}
.db-task-list a:hover,
.db-link-stack a:hover {
color: var(--link);
text-decoration: none;
border-color: color-mix(in oklab, var(--link) 45%, var(--db-line));
}
.db-task-list strong {
color: var(--muted);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.db-tags {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 0.9rem;
}
.db-tag {
padding: 0.28rem 0.62rem;
color: var(--chip-fg);
background: var(--chip-bg);
border: 1px solid var(--db-line);
border-radius: 999px;
font-size: 0.76rem;
font-weight: 750;
}
:root[data-theme="dark"] .db-root,
:root[data-theme="dark-academia"] .db-root {
--db-surface: color-mix(in oklab, var(--bg) 82%, var(--fg) 18%);
--db-surface-strong: color-mix(in oklab, var(--bg) 72%, var(--fg) 28%);
--db-line: color-mix(in oklab, var(--border) 80%, var(--fg) 20%);
--db-blue: #78a6ff;
--db-green: #63c995;
--db-amber: #d7a451;
--db-orange: #f29a62;
--db-red: #e28982;
}
:root[data-theme="dark-academia"] .db-root {
--db-blue: #c6b07a;
--db-green: #7fb092;
--db-amber: #d6b566;
--db-orange: #c98b52;
--db-red: #c76b71;
}
@media (max-width: 980px) {
.db-quicknav {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.db-panel,
.db-panel-stack,
.db-panel--wide,
.db-panel--stats,
.db-panel--focus,
.db-panel--about {
grid-column: span 6;
}
}
@media (max-width: 720px) {
.db-root {
padding-top: 0.75rem;
}
.db-hero,
.db-command {
grid-template-columns: 1fr;
}
.db-hero {
flex-direction: column;
}
.db-hero__brand {
align-items: flex-start;
}
.db-now {
min-width: 0;
}
.db-quicknav,
.db-grid {
grid-template-columns: 1fr;
}
.db-panel,
.db-panel-stack,
.db-panel--wide,
.db-panel--stats,
.db-panel--focus,
.db-panel--about {
grid-column: span 1;
}
.db-feed__item {
grid-template-columns: auto minmax(0, 1fr);
}
.db-feed__meta {
grid-column: 2;
}
}
@media (max-width: 460px) {
.db-hero__brand {
display: grid;
}
.db-hero__logo {
width: 58px;
height: 58px;
}
.db-qn-item {
min-height: 68px;
}
.db-stats-grid {
grid-template-columns: 1fr;
}
}

446
assets/styles/pages/play.css Executable file
View File

@@ -0,0 +1,446 @@
.play-root {
--play-ink: #2a2118;
--play-paper: color-mix(in oklab, var(--surface) 86%, #f4ead2 14%);
--play-brass: #9c6b2f;
--play-green: #486b57;
--play-red: #8a3f3f;
--play-blue: #3e5f8a;
max-width: 1100px;
margin: 2rem auto 4rem;
padding: 0 1rem;
}
.play-root * {
box-sizing: border-box;
}
.play-root.is-odd {
filter: saturate(1.08) contrast(1.02);
}
.play-hero,
.play-console,
.play-tool {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--play-paper);
box-shadow: 0 18px 45px rgba(35, 26, 16, 0.08);
}
.play-hero {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
align-items: center;
gap: 2rem;
padding: clamp(1.25rem, 4vw, 3rem);
overflow: hidden;
}
.play-kicker {
margin: 0 0 0.45rem;
color: var(--play-brass);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.play-hero h1,
.play-page-head h1 {
margin: 0;
color: var(--heading);
}
.play-lede,
.play-page-head p {
max-width: 62ch;
margin-top: 0.75rem;
color: var(--muted);
}
.play-orbit {
position: relative;
aspect-ratio: 1;
min-height: 300px;
border: 1px dashed color-mix(in oklab, var(--border) 70%, var(--play-brass));
border-radius: 50%;
background:
linear-gradient(90deg, transparent 49.8%, color-mix(in oklab, var(--border) 70%, transparent) 50%, transparent 50.2%),
linear-gradient(0deg, transparent 49.8%, color-mix(in oklab, var(--border) 70%, transparent) 50%, transparent 50.2%);
}
.play-orbit::after {
content: "PLAY";
position: absolute;
inset: 35%;
display: grid;
place-items: center;
border-radius: 50%;
border: 1px solid var(--border);
color: var(--muted);
font-weight: 800;
letter-spacing: 0.16em;
}
.play-orbit__node {
position: absolute;
width: 42px;
height: 42px;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--surface);
color: var(--fg);
cursor: pointer;
transform: translate(-50%, -50%);
}
.play-orbit__node.is-active,
.play-orbit__node:hover {
background: var(--play-brass);
color: #fff8e8;
}
.play-console {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
margin: 1rem 0;
padding: 1rem;
}
.play-console__screen {
min-height: 190px;
padding: 1.25rem;
border-radius: 6px;
background:
repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.025), rgba(0, 0, 0, 0.025) 1px, transparent 1px, transparent 7px),
color-mix(in oklab, var(--surface) 92%, var(--play-green) 8%);
}
.play-console__screen h2 {
margin: 0.2rem 0 0.5rem;
}
.play-whisper,
.play-live-note {
margin: 0.7rem 0 0;
color: var(--muted);
font-size: 0.92rem;
font-style: italic;
}
.play-console__eyebrow {
margin: 0;
color: var(--play-green);
font-weight: 800;
text-transform: uppercase;
}
.play-console__controls {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-content: start;
}
.play-root button,
.play-primary,
.sigil-form button {
min-height: 42px;
border: 1px solid color-mix(in oklab, var(--border) 70%, var(--play-brass));
border-radius: 6px;
padding: 0.55rem 0.85rem;
background: var(--surface);
color: var(--fg);
font: inherit;
cursor: pointer;
}
.play-root button:hover,
.play-primary:hover,
.sigil-form button:hover {
background: var(--play-ink);
color: #fff8e8;
text-decoration: none;
}
.play-primary {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 0.5rem;
background: var(--play-brass);
color: #fff8e8;
font-weight: 800;
}
.play-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 0.8rem;
}
.play-grid a {
min-height: 135px;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 88%, var(--play-paper) 12%);
color: var(--fg);
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
}
.play-grid a:hover,
.play-grid a.is-active {
transform: translateY(-3px);
border-color: var(--play-brass);
background: color-mix(in oklab, var(--surface) 76%, var(--play-brass) 24%);
text-decoration: none;
}
.play-grid span {
display: block;
color: var(--muted);
font-size: 0.8rem;
font-weight: 800;
}
.play-grid strong {
display: block;
margin-top: 2rem;
color: var(--heading);
font-size: 1.05rem;
}
.play-back {
display: inline-flex;
margin-bottom: 1rem;
color: var(--muted);
}
.play-page-head {
margin-bottom: 1rem;
}
.play-tool {
padding: 1rem;
}
.play-scorebar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
color: var(--muted);
}
.memory-board {
display: grid;
grid-template-columns: repeat(4, minmax(72px, 1fr));
gap: 0.7rem;
}
.memory-card {
aspect-ratio: 1.08;
border: 1px solid var(--border);
border-radius: 8px;
background:
radial-gradient(circle at 50% 38%, rgba(246, 216, 137, 0.15), transparent 28%),
linear-gradient(135deg, #211812, var(--play-ink));
color: #e7c978;
font-size: clamp(1.3rem, 4vw, 2.2rem);
font-weight: 900;
transform: rotate(var(--tilt, 0deg));
transition: transform 180ms ease, background 180ms ease, color 180ms ease;
}
.memory-card.is-open,
.memory-card.is-matched,
.memory-card.is-peeking {
background: color-mix(in oklab, var(--surface) 72%, var(--play-green) 28%);
color: var(--fg);
transform: rotate(0deg) translateY(-2px);
}
.memory-card.is-matched {
outline: 2px solid color-mix(in oklab, var(--play-brass) 70%, transparent);
}
.play-canvas,
.sigil-canvas {
display: block;
width: 100%;
max-height: 70vh;
border: 1px solid var(--border);
border-radius: 8px;
background: #18130f;
}
.poem-tool {
display: grid;
gap: 1rem;
}
.poem-slip {
min-height: 220px;
padding: 1.2rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
font-family: Georgia, "Times New Roman", serif;
font-size: 1.25rem;
line-height: 1.7;
}
.poem-slip[data-stamp]:not([data-stamp=""])::after {
content: attr(data-stamp);
display: inline-block;
margin-top: 1rem;
padding: 0.2rem 0.5rem;
border: 2px solid var(--play-red);
color: var(--play-red);
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
transform: rotate(-4deg);
}
.bookshelf {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: stretch;
}
.book-spine {
touch-action: none;
border: 1px solid var(--border);
border-radius: 7px;
padding: 0.8rem;
background: var(--surface);
cursor: grab;
user-select: none;
}
.book-spine {
width: 120px;
min-height: 230px;
writing-mode: vertical-rl;
text-orientation: mixed;
color: #fff8e8;
font-weight: 800;
}
.book-spine:nth-child(3n+1) { background: var(--play-red); }
.book-spine:nth-child(3n+2) { background: var(--play-green); }
.book-spine:nth-child(3n+3) { background: var(--play-blue); }
.booklet {
flex: 1 1 220px;
min-height: 110px;
padding: 0.9rem;
border: 1px dashed var(--play-brass);
border-radius: 7px;
background: color-mix(in oklab, var(--surface) 74%, var(--play-brass) 26%);
font-family: Georgia, "Times New Roman", serif;
font-weight: 700;
}
.terminal-tool {
background: #15120e;
color: #f2e3bd;
}
.terminal-log {
min-height: 310px;
max-height: 460px;
overflow: auto;
padding: 1rem;
border: 1px solid rgba(242, 227, 189, 0.2);
border-radius: 7px;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: pre-wrap;
}
.terminal-form {
display: flex;
gap: 0.6rem;
margin-top: 0.8rem;
}
.terminal-form label {
display: flex;
flex: 1;
align-items: center;
gap: 0.5rem;
}
.terminal-form input,
.sigil-form input {
width: 100%;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.65rem;
background: var(--surface);
color: var(--fg);
font: inherit;
}
.study-tool {
display: grid;
justify-items: center;
gap: 1rem;
}
.study-scene {
position: relative;
width: min(100%, 520px);
aspect-ratio: 1.6;
border: 1px solid var(--border);
border-radius: 8px;
background:
radial-gradient(circle at 50% 22%, rgba(244, 210, 113, var(--lamp, 0.18)), transparent 30%),
linear-gradient(#27211b 0 62%, #4b3425 62% 100%);
overflow: hidden;
}
.study-scene span {
position: absolute;
left: 42%;
bottom: 30%;
width: 16%;
height: 38%;
border-radius: 50% 50% 6px 6px;
background: #b88639;
box-shadow: 0 0 calc(20px + var(--glow, 0px)) rgba(244, 210, 113, 0.7);
}
.study-time {
font-size: clamp(2.4rem, 8vw, 5rem);
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.sigil-tool {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 520px);
gap: 1rem;
align-items: start;
}
.sigil-form {
display: grid;
gap: 0.85rem;
}
.sigil-form label {
display: grid;
gap: 0.35rem;
color: var(--muted);
}
.sigil-canvas {
background: var(--surface);
}

View File

@@ -0,0 +1,423 @@
.rl-app,
.rl-dialog {
--rl-ink: var(--fg);
--rl-paper: color-mix(in oklab, var(--surface) 94%, #efe5d4 6%);
--rl-paper-deep: color-mix(in oklab, var(--surface-soft) 82%, #d9bea0 18%);
--rl-line: color-mix(in oklab, var(--border) 80%, #9b6b43 20%);
--rl-accent: color-mix(in oklab, var(--accent) 54%, #9b6b43 46%);
--rl-accent-strong: color-mix(in oklab, var(--accent) 36%, #744729 64%);
--rl-muted: var(--muted);
font-family: var(--font-body);
}
.rl-app [hidden],
.rl-dialog [hidden] {
display: none !important;
}
.rl-app {
min-height: 72vh;
color: var(--rl-ink);
background:
radial-gradient(circle at 92% 4%, color-mix(in oklab, var(--rl-accent) 12%, transparent) 0, transparent 28rem),
linear-gradient(180deg, color-mix(in oklab, var(--rl-paper) 86%, transparent), transparent 36rem);
}
.rl-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
padding: 2.25rem 2rem 1.7rem;
border: 1px solid var(--rl-line);
border-radius: 18px 18px 0 0;
background: var(--rl-paper);
box-shadow: 0 18px 50px color-mix(in oklab, #000 8%, transparent);
}
.rl-hero h1,
.rl-library h2,
.rl-sidebar h2,
.rl-dialog h2,
.rl-dialog h3 {
font-family: Georgia, "Times New Roman", serif;
color: var(--heading);
margin: 0;
}
.rl-hero h1 {
font-size: clamp(2.1rem, 5vw, 4.25rem);
line-height: .95;
letter-spacing: -.04em;
}
.rl-hero__copy > p:last-child {
max-width: 42rem;
margin: .8rem 0 0;
color: var(--rl-muted);
}
.rl-eyebrow {
margin: 0 0 .45rem;
color: var(--rl-accent-strong);
font-size: .72rem;
font-weight: 850;
letter-spacing: .14em;
text-transform: uppercase;
}
.rl-hero__actions {
display: flex;
align-items: flex-end;
gap: .6rem;
min-width: min(100%, 22rem);
}
.rl-vault-picker {
display: grid;
gap: .35rem;
flex: 1;
color: var(--rl-muted);
font-size: .72rem;
font-weight: 750;
letter-spacing: .08em;
text-transform: uppercase;
}
.rl-app button,
.rl-app input,
.rl-app select,
.rl-dialog button,
.rl-dialog input,
.rl-dialog select,
.rl-dialog textarea {
box-sizing: border-box;
border: 1px solid var(--rl-line);
border-radius: 9px;
color: var(--rl-ink);
background: var(--surface);
font: inherit;
}
.rl-app button,
.rl-dialog button {
min-height: 2.55rem;
padding: .55rem .85rem;
font-size: .86rem;
font-weight: 800;
cursor: pointer;
}
.rl-app button:hover,
.rl-dialog button:hover,
.rl-app button:focus-visible,
.rl-dialog button:focus-visible {
border-color: var(--rl-accent);
background: color-mix(in oklab, var(--rl-accent) 9%, var(--surface));
}
.rl-app :focus-visible,
.rl-dialog :focus-visible {
outline: 3px solid color-mix(in oklab, var(--rl-accent) 38%, transparent);
outline-offset: 2px;
}
.rl-app .rl-primary,
.rl-dialog .rl-primary {
border-color: var(--rl-accent-strong);
color: #fffaf2;
background: var(--rl-accent-strong);
}
.rl-app .rl-primary:hover,
.rl-dialog .rl-primary:hover {
color: white;
background: color-mix(in oklab, var(--rl-accent-strong) 88%, #000);
}
.rl-icon-button { white-space: nowrap; }
.rl-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(8rem, 1.4fr);
border-inline: 1px solid var(--rl-line);
border-bottom: 1px solid var(--rl-line);
background: var(--rl-paper-deep);
}
.rl-summary > div {
display: flex;
align-items: baseline;
gap: .45rem;
padding: .9rem 1.15rem;
border-right: 1px solid var(--rl-line);
}
.rl-summary > div:last-child { border-right: 0; }
.rl-summary span { font-family: Georgia, serif; font-size: 1.55rem; font-weight: 800; color: var(--heading); }
.rl-summary small { color: var(--rl-muted); font-size: .72rem; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; }
.rl-summary .rl-summary__privacy { justify-content: flex-end; }
.rl-summary__privacy span { color: #4a8a5b; font-size: .8rem; }
.rl-summary__privacy[data-state="syncing"] span { color: #a87831; }
.rl-summary__privacy[data-state="offline"] span { color: #a13f35; }
.rl-summary__privacy[data-state="error"] span { color: #a13f35; }
.rl-command-bar {
display: flex;
flex-wrap: wrap;
gap: .55rem;
padding: 1rem;
border-inline: 1px solid var(--rl-line);
border-bottom: 1px solid var(--rl-line);
background: color-mix(in oklab, var(--surface) 88%, transparent);
}
.rl-workspace {
display: grid;
grid-template-columns: 15.5rem minmax(0, 1fr);
min-height: 36rem;
border-inline: 1px solid var(--rl-line);
border-bottom: 1px solid var(--rl-line);
border-radius: 0 0 18px 18px;
overflow: hidden;
background: var(--rl-paper);
}
.rl-sidebar {
padding: 1.3rem .9rem;
border-right: 1px solid var(--rl-line);
background: color-mix(in oklab, var(--rl-paper-deep) 72%, var(--surface));
}
.rl-sidebar__head,
.rl-library__toolbar,
.rl-sessions__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.rl-sidebar h2,
.rl-library h2 { font-size: 1.4rem; }
.rl-folder-list {
display: grid;
gap: .25rem;
margin-top: 1rem;
}
.rl-folder-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .25rem; }
.rl-folder-row > button:first-child {
display: flex;
justify-content: space-between;
align-items: center;
gap: .5rem;
width: 100%;
border-color: transparent;
text-align: left;
background: transparent;
}
.rl-folder-row > button:first-child span:last-child { color: var(--rl-muted); font-size: .75rem; }
.rl-folder-row.is-active > button:first-child { border-color: var(--rl-line); color: var(--heading); background: var(--surface); box-shadow: 0 5px 12px color-mix(in oklab, #000 6%, transparent); }
.rl-folder-menu { min-width: 2.45rem; padding-inline: .55rem !important; }
.rl-folder-rule { height: 1px; margin: .55rem .4rem; background: var(--rl-line); }
.rl-library { min-width: 0; padding: 1.35rem; }
.rl-library__toolbar { margin-bottom: 1rem; }
.rl-view-toggle { display: flex; gap: .2rem; padding: .2rem; border: 1px solid var(--rl-line); border-radius: 10px; }
.rl-view-toggle button { min-height: 2rem; padding: .3rem .65rem; border-color: transparent; background: transparent; }
.rl-view-toggle button.is-active { background: var(--rl-paper-deep); color: var(--heading); }
.rl-filters {
display: grid;
grid-template-columns: minmax(12rem, 2fr) repeat(4, minmax(7rem, 1fr));
gap: .55rem;
margin-bottom: 1rem;
}
.rl-filters input,
.rl-filters select,
.rl-vault-picker select { width: 100%; min-height: 2.65rem; padding: .55rem .7rem; }
.rl-resource-list { display: grid; gap: 1.15rem; min-width: 0; }
.rl-status-group {
--rl-status-colour: var(--rl-accent);
min-width: 0;
overflow: hidden;
border: 1px solid var(--rl-line);
border-radius: 14px;
background: color-mix(in oklab, var(--surface) 94%, var(--rl-status-colour) 6%);
}
.rl-status-group--active { --rl-status-colour: #3f7f5d; }
.rl-status-group--backlog { --rl-status-colour: #9b6b43; }
.rl-status-group--shelf { --rl-status-colour: #52769a; }
.rl-status-group--completed { --rl-status-colour: #7162a2; }
.rl-status-group--archived { --rl-status-colour: #77736d; }
.rl-status-group__head {
display: grid;
grid-template-columns: .7rem minmax(0, 1fr) auto;
align-items: center;
gap: .7rem;
padding: .75rem .85rem;
border-bottom: 1px solid var(--rl-line);
background: color-mix(in oklab, var(--surface) 82%, var(--rl-status-colour) 18%);
}
.rl-status-group__marker { width: .62rem; height: .62rem; border-radius: 50%; background: var(--rl-status-colour); box-shadow: 0 0 0 4px color-mix(in oklab, var(--rl-status-colour) 18%, transparent); }
.rl-status-group__title { min-width: 0; }
.rl-status-group__title strong { display: block; color: var(--heading); font-family: Georgia, serif; font-size: 1rem; }
.rl-status-group__title small { display: block; margin-top: .12rem; overflow: hidden; color: var(--rl-muted); font-size: .72rem; text-overflow: ellipsis; white-space: nowrap; }
.rl-status-group__count { min-width: 1.75rem; padding: .18rem .45rem; border-radius: 999px; color: var(--heading); background: color-mix(in oklab, var(--surface) 76%, transparent); font-size: .72rem; font-weight: 850; text-align: center; }
.rl-status-resources { display: grid; gap: .6rem; min-width: 0; padding: .65rem; }
.rl-resource-list.is-grid .rl-status-resources { grid-template-columns: repeat(auto-fit, minmax(min(13.5rem, 100%), 1fr)); }
.rl-resource-card {
box-sizing: border-box;
display: grid;
grid-template-columns: 3.5rem minmax(0, 1.25fr) minmax(8rem, .8fr) minmax(8rem, .65fr) auto;
align-items: center;
gap: .9rem;
min-width: 0;
width: 100%;
padding: .65rem;
border: 1px solid var(--rl-line);
border-radius: 12px;
color: var(--rl-ink);
background: var(--surface);
box-shadow: 0 5px 14px color-mix(in oklab, #000 5%, transparent);
text-align: left;
cursor: pointer;
}
.rl-resource-card > * { min-width: 0; }
.rl-resource-card:hover { transform: translateY(-1px); border-color: var(--rl-accent); }
.rl-resource-cover {
display: grid;
place-items: center;
width: 3.5rem;
aspect-ratio: 3 / 4;
overflow: hidden;
border-radius: 7px;
color: color-mix(in oklab, var(--rl-accent-strong) 70%, white);
background: linear-gradient(135deg, var(--rl-accent-strong), color-mix(in oklab, var(--rl-accent) 40%, var(--surface)));
font-family: Georgia, serif;
font-size: 1.3rem;
font-weight: 800;
}
.rl-resource-cover img { width: 100%; height: 100%; object-fit: cover; border-radius: 0 !important; }
.rl-resource-title { min-width: 0; }
.rl-resource-title strong { display: block; overflow: hidden; color: var(--heading); font-family: Georgia, serif; font-size: 1rem; text-overflow: ellipsis; white-space: nowrap; }
.rl-resource-title small,
.rl-resource-meta { display: block; margin-top: .25rem; color: var(--rl-muted); font-size: .76rem; }
.rl-badges { display: flex; flex-wrap: wrap; gap: .3rem; }
.rl-badge { padding: .22rem .42rem; border-radius: 999px; color: var(--rl-accent-strong); background: color-mix(in oklab, var(--rl-accent) 13%, var(--surface)); font-size: .68rem; font-weight: 800; text-transform: capitalize; }
.rl-progress { min-width: 7rem; }
.rl-progress__track { height: .42rem; overflow: hidden; border-radius: 999px; background: var(--rl-paper-deep); }
.rl-progress__fill { display: block; height: 100%; border-radius: inherit; background: var(--rl-accent-strong); }
.rl-progress small { display: block; margin-top: .3rem; color: var(--rl-muted); font-size: .7rem; text-align: right; }
.rl-card-arrow { color: var(--rl-muted); font-size: 1.2rem; }
.rl-resource-list.is-grid .rl-resource-card { grid-template-columns: 3.8rem minmax(0, 1fr); align-items: start; min-height: 9rem; height: auto; }
.rl-resource-list.is-grid .rl-badges,
.rl-resource-list.is-grid .rl-progress { grid-column: 1 / -1; }
.rl-resource-list.is-grid .rl-card-arrow { display: none; }
.rl-empty { padding: 4rem 1.5rem; border: 1px dashed var(--rl-line); border-radius: 14px; text-align: center; background: color-mix(in oklab, var(--surface) 58%, transparent); }
.rl-empty__mark { display: block; margin-bottom: .8rem; color: var(--rl-accent); font-family: Georgia, serif; font-size: 3rem; }
.rl-empty h3 { margin: 0; color: var(--heading); font-family: Georgia, serif; }
.rl-empty p { max-width: 32rem; margin: .5rem auto 1rem; color: var(--rl-muted); }
.rl-dialog { width: min(60rem, calc(100% - 2rem)); max-height: calc(100vh - 2rem); padding: 0; border: 1px solid var(--rl-line); border-radius: 16px; color: var(--rl-ink); background: var(--surface); box-shadow: 0 24px 80px rgba(0,0,0,.3); }
.rl-dialog::backdrop { background: rgba(12, 10, 8, .62); backdrop-filter: blur(3px); }
.rl-dialog__shell { display: flex; flex-direction: column; max-height: calc(100vh - 2rem); }
.rl-dialog__shell--small { width: min(32rem, 100%); margin: auto; }
.rl-dialog__head,
.rl-dialog__foot { display: flex; align-items: center; gap: .6rem; padding: 1rem 1.2rem; border-bottom: 1px solid var(--rl-line); background: var(--rl-paper); }
.rl-dialog__head { justify-content: space-between; }
.rl-dialog__foot { justify-content: flex-end; border-top: 1px solid var(--rl-line); border-bottom: 0; }
.rl-dialog__body { overflow: auto; padding: 1.2rem; }
.rl-dialog__spacer { flex: 1; }
.rl-close { width: 2.55rem; padding: 0 !important; font-size: 1.45rem !important; }
.rl-danger { color: #a12d2d !important; border-color: color-mix(in oklab, #a12d2d 45%, var(--rl-line)) !important; }
.rl-resource-dialog .rl-dialog__body { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: 1.2rem; }
.rl-resource-dialog.is-compact { width: min(46rem, calc(100% - 2rem)); }
.rl-resource-dialog.is-compact .rl-dialog__body { grid-template-columns: minmax(0, 1fr); }
.rl-resource-dialog.is-compact .rl-form-grid { max-width: 42rem; width: 100%; margin-inline: auto; }
.rl-cover-preview { display: grid; place-items: center; align-self: start; aspect-ratio: 3/4; overflow: hidden; border: 1px solid var(--rl-line); border-radius: 10px; color: var(--rl-muted); background: var(--rl-paper-deep); font-family: Georgia, serif; }
.rl-cover-preview img { width: 100%; height: 100%; object-fit: cover; }
.rl-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .85rem; }
.rl-field { display: grid; gap: .35rem; color: var(--rl-muted); font-size: .78rem; font-weight: 750; }
.rl-field--wide { grid-column: 1 / -1; }
.rl-field input,
.rl-field select,
.rl-field textarea { width: 100%; min-height: 2.55rem; padding: .55rem .65rem; resize: vertical; color: var(--rl-ink); }
.rl-sessions { grid-column: 1 / -1; padding-top: 1rem; border-top: 1px solid var(--rl-line); }
.rl-session-row { display: grid; grid-template-columns: 7rem minmax(8rem, auto) minmax(0, 1fr) auto; gap: .7rem; align-items: center; padding: .55rem 0; border-bottom: 1px solid var(--rl-line); font-size: .82rem; }
.rl-session-row small { color: var(--rl-muted); }
.rl-session-row button { min-height: 2rem; padding: .25rem .55rem; }
.rl-vault-row { display: grid; grid-template-columns: .8rem minmax(0, 1fr) auto auto; gap: .5rem; align-items: center; padding: .65rem 0; border-bottom: 1px solid var(--rl-line); }
.rl-vault-dot { width: .7rem; height: .7rem; border-radius: 50%; background: var(--vault-colour); }
.rl-import-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
.rl-import-summary div { padding: .8rem; border-radius: 10px; background: var(--rl-paper-deep); text-align: center; }
.rl-import-summary strong { display: block; color: var(--heading); font-family: Georgia, serif; font-size: 1.6rem; }
.rl-help { color: var(--rl-muted); font-size: .82rem; }
.rl-loading { display: none; position: fixed; inset: 0; z-index: 1000; place-items: center; align-content: center; color: white; background: rgba(12, 10, 8, .68); }
.rl-loading.is-visible { display: grid; }
.rl-loading span { width: 2.3rem; height: 2.3rem; border: 3px solid rgba(255,255,255,.3); border-top-color: white; border-radius: 50%; animation: rl-spin .8s linear infinite; }
.rl-loading p { margin: .8rem 0; }
@keyframes rl-spin { to { transform: rotate(360deg); } }
.rl-toast { position: fixed; right: 1.2rem; bottom: 1.2rem; z-index: 1100; max-width: min(25rem, calc(100vw - 2.4rem)); padding: .8rem 1rem; border: 1px solid var(--rl-line); border-radius: 10px; color: var(--rl-ink); background: var(--surface); box-shadow: 0 16px 45px rgba(0,0,0,.22); opacity: 0; transform: translateY(1rem); pointer-events: none; transition: .2s ease; }
.rl-toast.is-visible { opacity: 1; transform: translateY(0); }
.rl-toast.is-error { border-color: #a12d2d; }
@media (max-width: 900px) {
.rl-hero { align-items: stretch; flex-direction: column; }
.rl-summary { grid-template-columns: repeat(4, 1fr); }
.rl-summary__privacy { grid-column: 1 / -1; justify-content: flex-start !important; border-top: 1px solid var(--rl-line); }
.rl-workspace { grid-template-columns: 1fr; }
.rl-sidebar { border-right: 0; border-bottom: 1px solid var(--rl-line); }
.rl-folder-list { display: flex; overflow-x: auto; padding-bottom: .25rem; }
.rl-folder-row { flex: 0 0 auto; }
.rl-folder-row > button:first-child { width: auto; }
.rl-folder-rule { width: 1px; height: auto; margin: .3rem; }
.rl-filters { grid-template-columns: repeat(4, 1fr); }
.rl-search { grid-column: 1 / -1; }
.rl-resource-card { grid-template-columns: 3.5rem minmax(0, 1fr) auto; }
.rl-resource-card .rl-badges,
.rl-resource-card .rl-progress { grid-column: 2 / -1; }
}
@media (max-width: 620px) {
.rl-hero { padding: 1.4rem 1rem; border-radius: 12px 12px 0 0; }
.rl-hero__actions { align-items: stretch; flex-direction: column; min-width: 0; }
.rl-summary { grid-template-columns: repeat(2, 1fr); }
.rl-summary > div:nth-child(2) { border-right: 0; }
.rl-summary > div:nth-child(-n+2) { border-bottom: 1px solid var(--rl-line); }
.rl-command-bar button { flex: 1 1 8rem; }
.rl-library { padding: 1rem .7rem; }
.rl-library__toolbar { align-items: flex-start; }
.rl-filters { grid-template-columns: 1fr; }
.rl-search { grid-column: auto; }
.rl-resource-list.is-grid .rl-status-resources { grid-template-columns: 1fr; }
.rl-status-group__title small { white-space: normal; }
.rl-resource-card { grid-template-columns: 3.5rem minmax(0, 1fr); }
.rl-resource-card .rl-badges,
.rl-resource-card .rl-progress { grid-column: 1 / -1; }
.rl-card-arrow { display: none; }
.rl-resource-dialog .rl-dialog__body { grid-template-columns: 1fr; }
.rl-cover-preview { width: 7rem; }
.rl-form-grid { grid-template-columns: 1fr; }
.rl-field--wide { grid-column: auto; }
.rl-dialog__foot { flex-wrap: wrap; }
.rl-dialog__spacer { display: none; }
.rl-session-row { grid-template-columns: 1fr auto; }
.rl-session-row span:nth-child(3) { grid-column: 1 / -1; }
}
@media (prefers-reduced-motion: reduce) {
.rl-resource-card,
.rl-toast { transition: none; }
.rl-loading span { animation: none; }
}

File diff suppressed because it is too large Load Diff

833
assets/styles/play.css Normal file → Executable file
View File

@@ -1,832 +1 @@
.play-root { @import url("./pages/play.css");
--play-ink: #2a2118;
--play-paper: color-mix(in oklab, var(--surface) 86%, #f4ead2 14%);
--play-brass: #9c6b2f;
--play-green: #486b57;
--play-red: #8a3f3f;
--play-blue: #3e5f8a;
max-width: 1100px;
margin: 2rem auto 4rem;
padding: 0 1rem;
}
.play-root * {
box-sizing: border-box;
}
.play-root.is-odd {
filter: saturate(1.08) contrast(1.02);
}
.play-hero,
.play-console,
.play-tool {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--play-paper);
box-shadow: 0 18px 45px rgba(35, 26, 16, 0.08);
}
.play-hero {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
align-items: center;
gap: 2rem;
padding: clamp(1.25rem, 4vw, 3rem);
overflow: hidden;
}
.play-kicker {
margin: 0 0 0.45rem;
color: var(--play-brass);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.play-hero h1,
.play-page-head h1 {
margin: 0;
color: var(--heading);
}
.play-lede,
.play-page-head p {
max-width: 62ch;
margin-top: 0.75rem;
color: var(--muted);
}
.play-orbit {
position: relative;
aspect-ratio: 1;
min-height: 300px;
border: 1px dashed color-mix(in oklab, var(--border) 70%, var(--play-brass));
border-radius: 50%;
background:
linear-gradient(90deg, transparent 49.8%, color-mix(in oklab, var(--border) 70%, transparent) 50%, transparent 50.2%),
linear-gradient(0deg, transparent 49.8%, color-mix(in oklab, var(--border) 70%, transparent) 50%, transparent 50.2%);
}
.play-orbit::after {
content: "PLAY";
position: absolute;
inset: 35%;
display: grid;
place-items: center;
border-radius: 50%;
border: 1px solid var(--border);
color: var(--muted);
font-weight: 800;
letter-spacing: 0.16em;
}
.play-orbit__node {
position: absolute;
width: 42px;
height: 42px;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--surface);
color: var(--fg);
cursor: pointer;
transform: translate(-50%, -50%);
}
.play-orbit__node.is-active,
.play-orbit__node:hover {
background: var(--play-brass);
color: #fff8e8;
}
.play-console {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
margin: 1rem 0;
padding: 1rem;
}
.play-console__screen {
min-height: 190px;
padding: 1.25rem;
border-radius: 6px;
background:
repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.025), rgba(0, 0, 0, 0.025) 1px, transparent 1px, transparent 7px),
color-mix(in oklab, var(--surface) 92%, var(--play-green) 8%);
}
.play-console__screen h2 {
margin: 0.2rem 0 0.5rem;
}
.play-whisper,
.play-live-note {
margin: 0.7rem 0 0;
color: var(--muted);
font-size: 0.92rem;
font-style: italic;
}
.play-console__eyebrow {
margin: 0;
color: var(--play-green);
font-weight: 800;
text-transform: uppercase;
}
.play-console__controls {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-content: start;
}
.play-root button,
.play-primary,
.sigil-form button {
min-height: 42px;
border: 1px solid color-mix(in oklab, var(--border) 70%, var(--play-brass));
border-radius: 6px;
padding: 0.55rem 0.85rem;
background: var(--surface);
color: var(--fg);
font: inherit;
cursor: pointer;
}
.play-root button:hover,
.play-primary:hover,
.sigil-form button:hover {
background: var(--play-ink);
color: #fff8e8;
text-decoration: none;
}
.play-primary {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 0.5rem;
background: var(--play-brass);
color: #fff8e8;
font-weight: 800;
}
.play-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 0.8rem;
}
.play-grid a {
min-height: 135px;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 88%, var(--play-paper) 12%);
color: var(--fg);
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
}
.play-grid a:hover,
.play-grid a.is-active {
transform: translateY(-3px);
border-color: var(--play-brass);
background: color-mix(in oklab, var(--surface) 76%, var(--play-brass) 24%);
text-decoration: none;
}
.play-grid span {
display: block;
color: var(--muted);
font-size: 0.8rem;
font-weight: 800;
}
.play-grid strong {
display: block;
margin-top: 2rem;
color: var(--heading);
font-size: 1.05rem;
}
.play-back {
display: inline-flex;
margin-bottom: 1rem;
color: var(--muted);
}
.play-page-head {
margin-bottom: 1rem;
}
.play-tool {
padding: 1rem;
}
.play-scorebar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
color: var(--muted);
}
.memory-board {
display: grid;
grid-template-columns: repeat(4, minmax(72px, 1fr));
gap: 0.7rem;
}
.memory-card {
aspect-ratio: 1.08;
border: 1px solid var(--border);
border-radius: 8px;
background:
radial-gradient(circle at 50% 38%, rgba(246, 216, 137, 0.15), transparent 28%),
linear-gradient(135deg, #211812, var(--play-ink));
color: #e7c978;
font-size: clamp(1.3rem, 4vw, 2.2rem);
font-weight: 900;
transform: rotate(var(--tilt, 0deg));
transition: transform 180ms ease, background 180ms ease, color 180ms ease;
}
.memory-card.is-open,
.memory-card.is-matched,
.memory-card.is-peeking {
background: color-mix(in oklab, var(--surface) 72%, var(--play-green) 28%);
color: var(--fg);
transform: rotate(0deg) translateY(-2px);
}
.memory-card.is-matched {
outline: 2px solid color-mix(in oklab, var(--play-brass) 70%, transparent);
}
.play-canvas,
.sigil-canvas {
display: block;
width: 100%;
max-height: 70vh;
border: 1px solid var(--border);
border-radius: 8px;
background: #18130f;
}
.poem-tool {
display: grid;
gap: 1rem;
}
.poem-slip {
min-height: 220px;
padding: 1.2rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
font-family: Georgia, "Times New Roman", serif;
font-size: 1.25rem;
line-height: 1.7;
}
.poem-slip[data-stamp]:not([data-stamp=""])::after {
content: attr(data-stamp);
display: inline-block;
margin-top: 1rem;
padding: 0.2rem 0.5rem;
border: 2px solid var(--play-red);
color: var(--play-red);
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
transform: rotate(-4deg);
}
.bookshelf,
.timeline-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: stretch;
}
.book-spine,
.timeline-card {
touch-action: none;
border: 1px solid var(--border);
border-radius: 7px;
padding: 0.8rem;
background: var(--surface);
cursor: grab;
user-select: none;
}
.book-spine {
width: 120px;
min-height: 230px;
writing-mode: vertical-rl;
text-orientation: mixed;
color: #fff8e8;
font-weight: 800;
}
.book-spine:nth-child(3n+1) { background: var(--play-red); }
.book-spine:nth-child(3n+2) { background: var(--play-green); }
.book-spine:nth-child(3n+3) { background: var(--play-blue); }
.booklet {
flex: 1 1 220px;
min-height: 110px;
padding: 0.9rem;
border: 1px dashed var(--play-brass);
border-radius: 7px;
background: color-mix(in oklab, var(--surface) 74%, var(--play-brass) 26%);
font-family: Georgia, "Times New Roman", serif;
font-weight: 700;
}
.timeline-list {
flex-direction: column;
}
.timeline-card {
display: grid;
grid-template-columns: 5rem 1fr;
gap: 1rem;
}
.timeline-card span {
color: var(--play-brass);
font-weight: 900;
}
.recipe-tool {
display: grid;
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr);
gap: 1rem;
align-items: center;
}
.recipe-wheel {
aspect-ratio: 1;
width: 100%;
border-radius: 50% !important;
background:
conic-gradient(from 0deg, var(--play-red), var(--play-brass), var(--play-green), var(--play-blue), var(--play-red));
color: #fff8e8 !important;
font-weight: 900;
transition: transform 900ms cubic-bezier(.18, .88, .2, 1.15);
}
.recipe-result {
display: grid;
gap: 0.7rem;
padding: 0;
margin: 0;
list-style: none;
}
.recipe-result li {
padding: 0.85rem;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--surface);
}
.terminal-tool {
background: #15120e;
color: #f2e3bd;
}
.terminal-log {
min-height: 310px;
max-height: 460px;
overflow: auto;
padding: 1rem;
border: 1px solid rgba(242, 227, 189, 0.2);
border-radius: 7px;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: pre-wrap;
}
.terminal-form {
display: flex;
gap: 0.6rem;
margin-top: 0.8rem;
}
.terminal-form label {
display: flex;
flex: 1;
align-items: center;
gap: 0.5rem;
}
.terminal-form input,
.sigil-form input {
width: 100%;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.65rem;
background: var(--surface);
color: var(--fg);
font: inherit;
}
.study-tool {
display: grid;
justify-items: center;
gap: 1rem;
}
.study-scene {
position: relative;
width: min(100%, 520px);
aspect-ratio: 1.6;
border: 1px solid var(--border);
border-radius: 8px;
background:
radial-gradient(circle at 50% 22%, rgba(244, 210, 113, var(--lamp, 0.18)), transparent 30%),
linear-gradient(#27211b 0 62%, #4b3425 62% 100%);
overflow: hidden;
}
.study-scene span {
position: absolute;
left: 42%;
bottom: 30%;
width: 16%;
height: 38%;
border-radius: 50% 50% 6px 6px;
background: #b88639;
box-shadow: 0 0 calc(20px + var(--glow, 0px)) rgba(244, 210, 113, 0.7);
}
.study-time {
font-size: clamp(2.4rem, 8vw, 5rem);
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.sigil-tool {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 520px);
gap: 1rem;
align-items: start;
}
.sigil-form {
display: grid;
gap: 0.85rem;
}
.sigil-form label {
display: grid;
gap: 0.35rem;
color: var(--muted);
}
.sigil-canvas {
background: var(--surface);
}
.rpg-shell {
display: grid;
grid-template-columns: minmax(0, 960px) minmax(280px, 1fr);
gap: 1rem;
align-items: start;
}
.ash-rpg-root {
--ash-black: #05070c;
--ash-ink: #0d1420;
--ash-panel: #121a27;
--ash-line: #344154;
--ash-gold: #e0a451;
--ash-cold: #8bd3ff;
--ash-red: #db4f62;
--ash-text: #f8efe0;
--ash-muted: #a8b4c2;
max-width: 1380px;
}
.ash-rpg-root .play-page-head {
padding: 1rem 0;
border-bottom: 1px solid rgba(139, 211, 255, 0.18);
}
.ash-rpg-root .play-page-head h1 {
color: var(--ash-text);
font-family: Georgia, "Times New Roman", serif;
text-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
}
.ash-rpg-root .play-page-head p,
.ash-rpg-root .play-back {
color: var(--ash-muted);
}
.ash-rpg-shell {
position: relative;
border-color: rgba(139, 211, 255, 0.22);
background:
linear-gradient(180deg, rgba(12, 20, 32, 0.96), rgba(4, 7, 12, 0.98)),
var(--ash-black);
box-shadow:
0 0 0 1px rgba(224, 164, 81, 0.14) inset,
0 28px 80px rgba(0, 0, 0, 0.42);
}
.ash-rpg-shell::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-radius: 8px;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.025) 1px, transparent 1px, transparent 4px);
mix-blend-mode: screen;
opacity: 0.35;
}
.rpg-stage {
display: grid;
gap: 0.75rem;
}
.rpg-canvas {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
image-rendering: pixelated;
border: 2px solid var(--ash-line);
border-radius: 8px;
background: var(--ash-black);
box-shadow:
0 0 0 4px #05070c,
0 0 34px rgba(139, 211, 255, 0.16);
}
.ash-rpg-canvas {
min-height: 360px;
}
.rpg-dialogue,
.rpg-panel {
border: 1px solid var(--ash-line);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(21, 31, 46, 0.95), rgba(8, 12, 18, 0.96)),
var(--ash-panel);
color: var(--ash-text);
}
.rpg-dialogue {
min-height: 130px;
padding: 1rem;
box-shadow: 0 0 0 1px rgba(248, 239, 224, 0.05) inset;
}
.rpg-dialogue strong,
.rpg-list h2 {
color: var(--ash-gold);
}
.rpg-dialogue p,
.rpg-status {
margin: 0.35rem 0 0;
color: var(--ash-muted);
line-height: 1.55;
}
.rpg-panel {
display: grid;
gap: 1rem;
padding: 1rem;
align-content: start;
}
.rpg-actions,
.rpg-mobile-pad {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.ash-rpg-root button {
border-color: rgba(139, 211, 255, 0.28);
background: linear-gradient(180deg, #1a2638, #0c121c);
color: var(--ash-text);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-weight: 800;
text-transform: uppercase;
}
.ash-rpg-root button:hover:not(:disabled),
.ash-rpg-root button:focus-visible {
background: linear-gradient(180deg, #31435f, #142236);
color: #ffffff;
box-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
}
.ash-rpg-root button:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.rpg-mobile-pad {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rpg-mobile-pad [data-rpg-move="up"],
.rpg-mobile-pad [data-rpg-move="down"] {
grid-column: 2;
}
.rpg-mobile-pad [data-rpg-move="left"] {
grid-column: 1;
}
.rpg-mobile-pad [data-rpg-act] {
grid-column: 2;
}
.rpg-mobile-pad [data-rpg-move="right"] {
grid-column: 3;
}
.rpg-stats {
display: grid;
gap: 0.55rem;
margin: 0;
}
.rpg-stats div {
display: flex;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.45rem;
border-bottom: 1px solid rgba(139, 211, 255, 0.16);
}
.rpg-stats dt,
.rpg-list h2 {
margin: 0;
color: var(--ash-muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
}
.rpg-stats dd {
margin: 0;
font-weight: 800;
}
.rpg-list ul {
display: grid;
gap: 0.45rem;
margin: 0.45rem 0 0;
padding: 0;
list-style: none;
}
.rpg-list li {
padding: 0.55rem;
border: 1px solid rgba(139, 211, 255, 0.16);
border-radius: 6px;
background: rgba(5, 7, 12, 0.62);
color: var(--ash-text);
font-size: 0.92rem;
}
.rpg-list li.is-done {
border-color: rgba(102, 199, 162, 0.42);
background: rgba(45, 91, 77, 0.34);
}
.rpg-list li.is-erased {
border-color: rgba(219, 79, 98, 0.32);
color: rgba(248, 239, 224, 0.42);
background: repeating-linear-gradient(90deg, rgba(219, 79, 98, 0.08), rgba(219, 79, 98, 0.08) 6px, rgba(5, 7, 12, 0.6) 6px, rgba(5, 7, 12, 0.6) 12px);
text-decoration: line-through;
}
.ash-choicebar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.ash-choicebar button {
min-height: 50px;
}
.ash-memory-meter {
position: relative;
display: grid;
gap: 0.45rem;
color: var(--ash-muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
}
.ash-memory-meter::after {
content: "";
display: block;
height: 10px;
border: 1px solid rgba(139, 211, 255, 0.24);
border-radius: 999px;
background: #05070c;
}
.ash-memory-meter i {
position: absolute;
left: 1px;
right: auto;
bottom: 1px;
width: 0;
height: 10px;
border-radius: 999px;
background: linear-gradient(90deg, var(--ash-cold), var(--ash-red));
transition: width 220ms ease;
}
.ash-name-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
align-items: end;
}
.ash-name-form label {
display: grid;
gap: 0.35rem;
color: var(--ash-muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
}
.ash-name-form input {
width: 100%;
border: 1px solid rgba(139, 211, 255, 0.24);
border-radius: 6px;
padding: 0.65rem;
background: #05070c;
color: var(--ash-text);
font: inherit;
}
.ash-rpg-root.is-lake-awake .rpg-dialogue,
.ash-rpg-root.is-lake-awake .rpg-panel {
border-color: rgba(219, 79, 98, 0.36);
}
.ash-rpg-root.is-lake-awake .ash-rpg-canvas {
box-shadow:
0 0 0 4px #05070c,
0 0 38px rgba(219, 79, 98, 0.22);
}
.ash-rpg-root.is-ending .rpg-panel {
box-shadow: 0 0 34px rgba(224, 164, 81, 0.16);
}
@media (max-width: 760px) {
.play-hero,
.play-console,
.recipe-tool,
.sigil-tool,
.rpg-shell {
grid-template-columns: 1fr;
}
.play-orbit {
min-height: 260px;
}
.memory-board {
grid-template-columns: repeat(3, minmax(64px, 1fr));
}
.book-spine {
width: calc(50% - 0.4rem);
min-height: 160px;
}
.ash-choicebar,
.ash-name-form {
grid-template-columns: 1fr;
}
.ash-rpg-canvas {
min-height: auto;
}
}

View File

@@ -1,875 +1,5 @@
:root{ @import url("./themes/tokens.css");
--gutter: 2rem; @import url("./base/elements.css");
--margin: 420px; @import url("./layout/site-shell.css");
--body-pad: 1rem; @import url("./components/notes-board.css");
--content: clamp( @import url("./components/mermaid.css");
var(--content-min),
calc(100vi - 2*var(--body-pad) - 2*(var(--margin) + var(--gutter))),
var(--content-max)
);
--content-min: 60ch;
--content-max: 880px;
--bleed: 48px;
--fullwidth-cap: 860px;
--bg: #faf9f6;
--fg: #000000;
--link: #1a0dab;
--heading: #004c99;
--surface: #ffffff;
--surface-soft: #f3f1eb;
--accent: #2563eb;
--code-bg: #f0f0f0;
--active-toc: #cacaca;
--note-color: #555;
--note-bg: transparent;
--border: #d7d7d7;
--chip-bg: #f0f0f0;
--chip-fg: #444;
--muted: #666;
--bg-color: var(--surface);
--border-color: var(--border);
--border-muted: var(--border);
--text-muted: var(--muted);
--accent-bg: color-mix(in oklab, var(--accent) 10%, var(--surface));
--font-body: 'DM Sans', system-ui, sans-serif;
--kb-bg-completed: #e6f4ea;
--kb-bg-manager-review: #fff4e5;
--kb-bg-in-progress: #e8f0fe;
--kb-bg-not-started: #f1f3f5;
--kb-bg-comments: #fdecea;
--kb-column-border: rgba(0,0,0,0.06);
--kb-card-bg: #ffffff;
--kb-card-text: #1f2933;
}
:root[data-theme="dark"]{
--bg: #0f1115;
--fg: #e6e6e6;
--link: #8ab4ff;
--heading: #9ecbff;
--surface: #151820;
--surface-soft: #1b202a;
--accent: #8ab4ff;
--code-bg: #1a1d24;
--note-color: #c2c7cf;
--note-bg: transparent;
--border: #2a2f3a;
--chip-bg: #1f2330;
--chip-fg: #cfd3da;
--muted: #a9b0bb;
--active-toc: #313131;
--kb-bg-completed: #1e3a2b;
--kb-bg-manager-review: #3a2f1e;
--kb-bg-in-progress: #1f2a44;
--kb-bg-not-started: #2a2d31;
--kb-bg-comments: #3b1f24;
--kb-column-border: rgba(255,255,255,0.08);
--kb-card-bg: #16181c;
--kb-card-text: #e5e7eb;
}
:root[data-theme="dark-academia"]{
color-scheme: dark;
--bg: #11100d;
--fg: #f7ecd5;
--link: #d8b86f;
--heading: #e8cf91;
--surface: #1d1a16;
--surface-soft: #242019;
--accent: #b89145;
--code-bg: #191814;
--active-toc: rgba(184, 145, 69, 0.18);
--note-color: #d9c9aa;
--note-bg: rgba(243, 234, 215, 0.05);
--border: rgba(217, 190, 124, 0.32);
--chip-bg: rgba(184, 145, 69, 0.14);
--chip-fg: #f0d89a;
--muted: #b6a681;
--bg-color: var(--surface);
--border-color: var(--border);
--border-muted: rgba(217, 190, 124, 0.24);
--text-muted: var(--muted);
--accent-bg: rgba(184, 145, 69, 0.16);
--kb-bg-completed: rgba(68, 106, 87, 0.24);
--kb-bg-manager-review: rgba(184, 145, 69, 0.2);
--kb-bg-in-progress: rgba(82, 93, 117, 0.22);
--kb-bg-not-started: rgba(243, 234, 215, 0.08);
--kb-bg-comments: rgba(125, 47, 54, 0.24);
--kb-column-border: rgba(217, 190, 124, 0.2);
--kb-card-bg: #181510;
--kb-card-text: #f3ead7;
}
@font-face {
font-family: 'Noto';
src: url('../fonts/NotoSans-Regular.ttf') format('truetype');
}
html, body{
background:
radial-gradient(circle at 18% 12%, var(--theme-glow-wine, transparent), transparent 28%),
radial-gradient(circle at 84% 18%, var(--theme-glow-green, transparent), transparent 24%),
linear-gradient(135deg, var(--theme-bg-start, var(--bg)) 0%, var(--theme-bg-mid, var(--bg)) 42%, var(--theme-bg-end, var(--bg)) 100%);
color: var(--fg);
font-size: 16px;
transition: background-color .3s, color .3s;
margin: 0;
font-family: 'Noto', system-ui, sans-serif;
line-height: 1.65;
}
body {
min-height: 100vh;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
:root[data-theme="dark-academia"] body {
--theme-glow-wine: rgba(125, 47, 54, 0.24);
--theme-glow-green: rgba(68, 106, 87, 0.24);
--theme-bg-start: #0f0e0b;
--theme-bg-mid: #17140f;
--theme-bg-end: #211b14;
}
:root[data-theme="dark-academia"] body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.16;
background-image:
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
background-size: 34px 34px, 34px 34px;
mask-image: linear-gradient(90deg, rgba(0,0,0,0.9), rgba(0,0,0,0.35));
}
::selection {
background: color-mix(in oklab, var(--accent) 24%, transparent);
}
h1, h2, h3{
color: var(--heading);
line-height: 1.18;
letter-spacing: 0;
}
h1 {
margin-top: 2.25rem;
margin-bottom: 1rem;
font-size: clamp(2rem, 4vw, 3.4rem);
}
h2 {
margin-top: 2.2rem;
}
p {
margin: 0 0 1.15rem;
}
a {
color: var(--link);
text-decoration: none;
text-underline-offset: 0.18em;
}
a:hover {
text-decoration: underline;
}
.banner-header{
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.85rem;
padding: 0.7rem;
margin-top: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 88%, var(--bg) 12%);
box-shadow: 0 12px 32px color-mix(in oklab, #000 8%, transparent);
}
:root[data-theme="dark-academia"] .banner-header,
:root[data-theme="dark-academia"] .org-src-container,
:root[data-theme="dark-academia"] #notes-form,
:root[data-theme="dark-academia"] .note {
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
:root[data-theme="dark-academia"] h1,
:root[data-theme="dark-academia"] h2,
:root[data-theme="dark-academia"] h3 {
font-family: Georgia, "Times New Roman", serif;
}
.site-brand {
display: inline-flex;
align-items: center;
gap: 0.7rem;
color: var(--fg);
font-weight: 800;
white-space: nowrap;
}
.site-brand:hover {
text-decoration: none;
color: var(--heading);
}
.site-brand__text {
font-size: 0.98rem;
}
.banner-logo{
height: 42px;
width: 42px;
object-fit: cover;
border-radius: 50%;
border: 1px solid color-mix(in oklab, var(--border) 80%, transparent);
}
.site-nav {
display: flex;
align-items: center;
justify-content: center;
flex: 1 1 auto;
gap: 0.25rem;
font-weight: 700;
font-size: 0.95rem;
}
.site-nav a {
color: var(--muted);
padding: 0.45rem 0.65rem;
border-radius: 7px;
}
.site-nav a:hover,
.site-nav a:focus-visible {
color: var(--fg);
background: color-mix(in oklab, var(--fg) 7%, transparent);
text-decoration: none;
outline: 0;
}
.site-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
flex: 0 1 23rem;
}
.site-search {
display: block;
flex: 1 1 13rem;
min-width: 10rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
#preamble.status{
max-width: var(--content);
margin-left: auto;
margin-right: auto;
padding-left: var(--body-pad);
padding-right: var(--body-pad);
padding-top: 0.25rem;
box-sizing: content-box;
}
#preamble .banner-header,
#preamble #updated{
max-width: 100%;
}
footer{
color: var(--muted);
padding: 1.25rem;
margin-top: 3rem;
border-top: 1px solid var(--border);
text-align: center;
font-size: .9rem;
}
#content.content{
max-width: var(--content);
margin-left: auto !important;
margin-right: auto !important;
padding-left: var(--body-pad);
padding-right: var(--body-pad);
box-sizing: content-box;
position: relative;
padding-top: 1.5rem;
}
#content .figure,
#content img:not(.fullwidth){
max-width: 100%;
height: auto;
border-radius: 8px;
}
hr {
border: 0;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
overflow: hidden;
}
th,
td {
border-bottom: 1px solid var(--border);
padding: 0.65rem 0.75rem;
text-align: left;
}
th {
color: var(--heading);
background: color-mix(in oklab, var(--surface-soft) 80%, transparent);
}
blockquote {
margin: 1.5rem 0;
padding: 0.9rem 1.1rem;
border-left: 4px solid var(--heading);
background: color-mix(in oklab, var(--surface-soft) 72%, transparent);
border-radius: 0 8px 8px 0;
color: color-mix(in oklab, var(--fg) 84%, var(--muted) 16%);
}
ul, ol {
margin: 1rem 0 1.5rem 1.5rem;
padding: 0;
line-height: 1.5;
}
ul {
list-style: none;
}
ul li {
position: relative;
padding-left: 1.2em;
}
ul li::before {
content: "•";
position: absolute;
left: 0;
top: 0;
color: var(--heading);
font-weight: bold;
}
ol {
counter-reset: list-counter;
list-style: none;
}
ol li {
counter-increment: list-counter;
position: relative;
padding-left: 1.8em;
}
ol li::before {
content: counter(list-counter) ".";
position: absolute;
left: 0;
top: 0;
color: var(--heading);
font-weight: bold;
}
li {
margin-bottom: 8px;
display: flow-root;
}
li::after {
content: none;
}
li ul,
li ol {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
li ul li::before {
content: "";
font-weight: normal;
color: var(--note-color);
}
li ol li::before {
font-weight: normal;
color: var(--note-color);
}
.epigraph {
margin: 2rem auto;
max-width: 80%;
font-style: italic;
}
.epigraph blockquote {
margin: 0;
padding: 1rem 1.5rem;
border-left: 4px solid var(--heading);
background-color: rgba(0, 0, 0, 0.02);
color: var(--fg);
line-height: 1.6;
}
.epigraph blockquote footer {
margin-top: 0.75rem;
font-style: normal;
font-size: 0.9em;
color: var(--note-color);
text-align: right;
}
.epigraph blockquote cite {
font-style: italic;
font-weight: 500;
color: var(--link);
}
.epigraph blockquote::before,
.epigraph blockquote::after {
content: none;
}
.epigraph blockquote{
border-left-color: var(--heading);
background-color: color-mix(in oklab, var(--bg) 94%, var(--fg) 6%);
color: var(--fg);
}
.epigraph blockquote footer{ color: var(--muted); }
.hidden { display: none !important; }
.bp-x{ right:72px; }
.bp-next,.bp-prev{ right:8px; }
.bp-prev{ left:8px; }
.bp-wrap{ transition: opacity .18s ease; }
.bp-wrap.bp-fadeout{ opacity: 0; }
.bp-controls{
padding-top: env(safe-area-inset-top, 0);
padding-right: calc(env(safe-area-inset-right, 0) + 8px);
}
body.no-sidenotes {
--body-pad: 0;
--margin: 0;
--gutter: 0;
--content-min: 80ch;
--content-max: 1200px;
--content: clamp(
var(--content-min),
100vi,
var(--content-max)
);
}
/* =========================
NOTES BOARD
========================= */
#notes-tools {
display: flex;
align-items: end;
justify-content: flex-end;
gap: 0.65rem;
margin: 1.25rem 0 0;
}
#notes-tools label {
color: var(--muted);
font-size: 0.75rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
#notes-author-filter {
min-width: min(220px, 100%);
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--fg);
font: inherit;
font-size: 0.9rem;
line-height: 1.2;
padding: 0.45rem 0.65rem;
}
#notes-author-filter:focus {
outline: 2px solid color-mix(in oklab, var(--accent) 45%, transparent);
outline-offset: 2px;
}
#notes-wall {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
margin: 2.5rem 0;
align-items: start;
}
/* =========================
NOTE CARD
========================= */
.note {
position: relative;
padding: 1rem 1rem 0.9rem;
background: var(--surface);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.08),
0 12px 24px rgba(0, 0, 0, 0.06);
transform: rotate(var(--note-tilt, 0deg));
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
background-color 0.2s ease,
border-color 0.2s ease;
}
/* subtle randomness */
.note:nth-child(3n) { --note-tilt: -0.6deg; }
.note:nth-child(4n) { --note-tilt: 0.4deg; }
.note:nth-child(5n) { --note-tilt: -0.3deg; }
.note:hover {
transform: rotate(0deg) translateY(-4px);
box-shadow:
0 6px 14px rgba(0, 0, 0, 0.12),
0 18px 36px rgba(0, 0, 0, 0.10);
}
/* =========================
PIN
========================= */
.note::before {
content: "";
position: absolute;
top: 0.6rem;
left: 50%;
width: 10px;
height: 10px;
background: #c33;
border-radius: 50%;
transform: translateX(-50%);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.3);
opacity: 0.85;
}
/* =========================
META
========================= */
.note-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.7rem;
margin-bottom: 0.5rem;
color: var(--muted);
}
.note-author {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.note-meta time {
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
/* =========================
CONTENT
========================= */
.note-content {
font-size: 0.95rem;
line-height: 1.45;
white-space: pre-wrap;
color: var(--fg);
}
/* =========================
MOBILE TWEAKS
========================= */
@media (max-width: 600px) {
#notes-wall {
grid-template-columns: 1fr;
}
#notes-tools {
align-items: stretch;
flex-direction: column;
}
.note {
transform: none;
}
.note::before {
left: 0.75rem;
transform: none;
}
}
/* =========================
NOTES FORM
========================= */
#notes-form-wrapper {
margin-top: 3rem;
display: flex;
justify-content: center;
}
#notes-form {
width: 100%;
max-width: 420px;
background: var(--surface);
color: var(--fg);
border: 1px solid var(--border);
padding: 1.25rem 1.25rem 1.1rem;
border-radius: 10px;
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.08),
0 14px 30px rgba(0, 0, 0, 0.06);
position: relative;
}
/* pin */
#notes-form::before {
content: "";
position: absolute;
top: 0.6rem;
left: 50%;
width: 12px;
height: 12px;
background: #c33;
border-radius: 50%;
transform: translateX(-50%);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.35);
}
/* =========================
LABELS
========================= */
#notes-form label {
display: block;
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.25rem;
}
/* =========================
INPUTS
========================= */
#notes-form input,
#notes-form textarea {
width: 100%;
border: none;
background: transparent;
color: var(--fg);
font-family: inherit;
font-size: 0.95rem;
line-height: 1.45;
padding: 0.3rem 0;
margin-bottom: 0.9rem;
border-bottom: 1px solid var(--border);
}
#notes-form textarea {
resize: vertical;
min-height: 4.5rem;
}
/* focus state */
#notes-form input:focus,
#notes-form textarea:focus {
outline: none;
border-bottom-color: var(--accent);
}
/* placeholder */
#notes-form ::placeholder {
color: var(--muted);
}
/* =========================
SUBMIT BUTTON
========================= */
#notes-form button {
margin-top: 0.5rem;
padding: 0.35rem 0.9rem;
font-family: inherit;
font-size: 0.8rem;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 999px;
border: none;
background: var(--fg);
color: var(--bg);
cursor: pointer;
float: right;
}
#notes-form button:hover {
background: var(--accent);
color: var(--surface);
}
#notes-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* =========================
MOBILE
========================= */
@media (max-width: 600px) {
#notes-form {
max-width: 100%;
}
}
/* Mermaid container for zooming and panning */
.mermaid-container {
overflow: auto;
width: 100%;
min-height: 26rem;
max-height: 800px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 88%, var(--bg) 12%);
position: relative;
margin: 1.5rem 0;
}
/* Make SVG scale properly */
.mermaid-container .mermaid svg {
transform-origin: top left; /* needed for scaling */
transition: transform 0.2s ease; /* smooth zoom */
width: 100%;
height: auto;
}
.mermaid-container--site-map {
width: min(96vw, 1400px);
height: min(82vh, 980px);
max-height: none;
margin-left: 50%;
transform: translateX(-50%);
}
.mermaid-container--site-map .mermaid {
display: inline-block;
min-width: max-content;
padding: 3rem 2rem 2rem;
}
.mermaid-container--site-map .mermaid svg {
width: auto;
max-width: none;
min-width: 1100px;
}
@media (max-width: 700px) {
.mermaid-container--site-map {
width: 100%;
height: 78vh;
margin-left: 0;
transform: none;
}
.mermaid-container--site-map .mermaid svg {
min-width: 900px;
}
}
/* Zoom buttons */
.mermaid-zoom-controls {
position: absolute;
top: 5px;
right: 5px;
z-index: 10;
}
.mermaid-zoom-controls button {
padding: 5px 10px;
margin-left: 5px;
background: var(--surface);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.mermaid-zoom-controls button:hover {
background: var(--accent);
color: var(--surface);
}

110
assets/styles/themes/tokens.css Executable file
View File

@@ -0,0 +1,110 @@
:root{
--gutter: 2rem;
--margin: 420px;
--body-pad: 1rem;
--content: clamp(
var(--content-min),
calc(100vi - 2*var(--body-pad) - 2*(var(--margin) + var(--gutter))),
var(--content-max)
);
--content-min: 60ch;
--content-max: 880px;
--bleed: 48px;
--fullwidth-cap: 860px;
--bg: #faf9f6;
--fg: #000000;
--link: #1a0dab;
--heading: #004c99;
--surface: #ffffff;
--surface-soft: #f3f1eb;
--accent: #2563eb;
--code-bg: #f0f0f0;
--active-toc: #cacaca;
--note-color: #555;
--note-bg: transparent;
--border: #d7d7d7;
--chip-bg: #f0f0f0;
--chip-fg: #444;
--muted: #666;
--bg-color: var(--surface);
--border-color: var(--border);
--border-muted: var(--border);
--text-muted: var(--muted);
--accent-bg: color-mix(in oklab, var(--accent) 10%, var(--surface));
--font-body: 'DM Sans', system-ui, sans-serif;
--kb-bg-completed: #e6f4ea;
--kb-bg-manager-review: #fff4e5;
--kb-bg-in-progress: #e8f0fe;
--kb-bg-not-started: #f1f3f5;
--kb-bg-comments: #fdecea;
--kb-column-border: rgba(0,0,0,0.06);
--kb-card-bg: #ffffff;
--kb-card-text: #1f2933;
}
:root[data-theme="dark"]{
--bg: #0f1115;
--fg: #e6e6e6;
--link: #8ab4ff;
--heading: #9ecbff;
--surface: #151820;
--surface-soft: #1b202a;
--accent: #8ab4ff;
--code-bg: #1a1d24;
--note-color: #c2c7cf;
--note-bg: transparent;
--border: #2a2f3a;
--chip-bg: #1f2330;
--chip-fg: #cfd3da;
--muted: #a9b0bb;
--active-toc: #313131;
--kb-bg-completed: #1e3a2b;
--kb-bg-manager-review: #3a2f1e;
--kb-bg-in-progress: #1f2a44;
--kb-bg-not-started: #2a2d31;
--kb-bg-comments: #3b1f24;
--kb-column-border: rgba(255,255,255,0.08);
--kb-card-bg: #16181c;
--kb-card-text: #e5e7eb;
}
:root[data-theme="dark-academia"]{
color-scheme: dark;
--bg: #11100d;
--fg: #f7ecd5;
--link: #d8b86f;
--heading: #e8cf91;
--surface: #1d1a16;
--surface-soft: #242019;
--accent: #b89145;
--code-bg: #191814;
--active-toc: rgba(184, 145, 69, 0.18);
--note-color: #d9c9aa;
--note-bg: rgba(243, 234, 215, 0.05);
--border: rgba(217, 190, 124, 0.32);
--chip-bg: rgba(184, 145, 69, 0.14);
--chip-fg: #f0d89a;
--muted: #b6a681;
--bg-color: var(--surface);
--border-color: var(--border);
--border-muted: rgba(217, 190, 124, 0.24);
--text-muted: var(--muted);
--accent-bg: rgba(184, 145, 69, 0.16);
--kb-bg-completed: rgba(68, 106, 87, 0.24);
--kb-bg-manager-review: rgba(184, 145, 69, 0.2);
--kb-bg-in-progress: rgba(82, 93, 117, 0.22);
--kb-bg-not-started: rgba(243, 234, 215, 0.08);
--kb-bg-comments: rgba(125, 47, 54, 0.24);
--kb-column-border: rgba(217, 190, 124, 0.2);
--kb-card-bg: #181510;
--kb-card-text: #f3ead7;
}

View File

@@ -0,0 +1,134 @@
@media (max-width: 600px){
.banner-header {
align-items: stretch;
gap: 0.7rem;
margin-top: 0.75rem;
}
.site-brand {
justify-content: center;
}
.site-nav {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
width: 100%;
gap: 0.2rem;
font-size: 0.92rem;
}
.site-nav a {
padding: 0.42rem 0.55rem;
}
.site-actions {
width: 100%;
flex: 1 1 100%;
justify-content: center;
}
.site-search {
display: none;
}
#updated {
text-align: center;
}
body.no-sidenotes {
margin-left: 1em;
margin-right: 1em;
}
#mobile-move-panel {
display: block;
}
}
@media (prefers-color-scheme: dark){
:root:not([data-theme]){
--bg: #0f1115;
--fg: #e6e6e6;
--link: #8ab4ff;
--heading: #9ecbff;
--code-bg: #1a1d24;
--note-color: #c2c7cf;
--note-bg: transparent;
--border: #2a2f3a;
--chip-bg: #1f2330;
--chip-fg: #cfd3da;
--muted: #a9b0bb;
--surface: #151820;
--surface-soft: #1b202a;
--accent: #8ab4ff;
--bg-color: var(--surface);
--border-color: var(--border);
--border-muted: var(--border);
--text-muted: var(--muted);
--accent-bg: color-mix(in oklab, var(--accent) 12%, var(--surface));
}
.countdown-wrap {
color: var(--fg, #ddd);
}
time.countdown {
color: var(--accent, #7abfff);
background: color-mix(in srgb, var(--accent, #7abfff) 15%, transparent);
box-shadow: 0 0 6px rgba(255,255,255,0.05);
}
time.countdown.expired {
color: #777;
}
}
@media (max-width: 1250px){
:root {
--content-min: 0px;
--content: min(var(--content-max), calc(100vi - 2 * var(--body-pad)));
}
#preamble.status{
padding-right: var(--body-pad);
}
#content.content{
padding-right: var(--body-pad);
}
.sidenote,
.marginnote{
float: none;
clear: both;
width: auto;
display: block;
margin: 0.5rem 0 0.75rem;
padding-left: 0.75rem;
border-left: 3px solid rgba(0,0,0,.08);
margin-right: 0;
}
.fullwidth{
max-width: calc(100vw - 2 * var(--body-pad));
}
.sidenote, .marginnote{
border-left: 3px solid color-mix(in oklab, var(--fg) 12%, transparent);
}
.sidenote .mn-img,
.marginnote .mn-img{
border-color: color-mix(in oklab, var(--fg) 12%, transparent);
}
#table-of-contents{
float: none;
position: static;
width: auto;
margin: 0 0 1rem;
padding: .5rem .75rem;
border-right: 0;
border-left: 3px solid color-mix(in oklab, var(--fg) 12%, transparent);
background: color-mix(in oklab, var(--bg) 96%, var(--fg) 4%);
border-radius: 4px;
}
}

Some files were not shown because too many files have changed in this diff Show More