Compare commits
47 Commits
a980d6393e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74924ea06f | ||
|
|
4653a2caae | ||
|
|
7a60d33610 | ||
|
|
0b4a68b81b | ||
|
|
65885dbb3a | ||
|
|
8dbeca7570 | ||
|
|
b8bb1944dd | ||
|
|
75af3e1f0e | ||
|
|
d12edea8de | ||
|
|
2ee6575539 | ||
|
|
08516fda14 | ||
|
|
262d943443 | ||
|
|
4659edc658 | ||
|
|
563d4b63cc | ||
|
|
6066f486de | ||
|
|
a6502b168d | ||
|
|
15b626ee0d | ||
|
|
a0c78929b3 | ||
|
|
fe8acac707 | ||
|
|
dc1ca5c00e | ||
|
|
2f125f32ae | ||
|
|
c06af38d80 | ||
|
|
5b59f5745c | ||
|
|
7afc554f5d | ||
|
|
db90e9f452 | ||
|
|
8dd05d4d03 | ||
|
|
67ab0d2ddb | ||
|
|
9d758f5254 | ||
|
|
4df4cd1055 | ||
|
|
2fb88916d5 | ||
|
|
e82fdf1230 | ||
|
|
f4b388d70b | ||
|
|
90c8321ba3 | ||
|
|
4c9294490e | ||
|
|
0d56f36a26 | ||
|
|
275a955a5f | ||
|
|
af79b439ce | ||
|
|
17e02977d6 | ||
|
|
9b259ac173 | ||
|
|
2bb816b2ae | ||
|
|
76a708e8fc | ||
| 1883097397 | |||
|
|
0db2151f92 | ||
|
|
d0610a5907 | ||
|
|
53df5b46a3 | ||
|
|
b7f08ba46d | ||
|
|
832eb1a913 |
@@ -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
@@ -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
|
||||||
|
|||||||
10
Makefile
@@ -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
@@ -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.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 110 KiB |
50
assets/content/comment-pages.json
Normal file → Executable 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
23
assets/content/resource-loader.json
Executable 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
0
assets/icons/pen-icon.png
Normal file → Executable 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
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
0
assets/images/blogs/2026-05-12-screenshot-2026-05-12-163301.png
Normal file → Executable 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
|
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
|
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
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
0
assets/images/blogs/2026-05-13-screenshot-2026-05-13-131807.png
Normal file → Executable 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
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
0
assets/images/blogs/2026-05-27-screenshot-2026-05-27-160508.png
Normal file → Executable file
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
0
assets/images/blogs/2026-06-04-screenshot-2026-06-04-143726.png
Normal file → Executable 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
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
0
assets/images/reviews/timesheets/2026-02-01-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
0
assets/images/reviews/timesheets/2026-02-08-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
0
assets/images/reviews/timesheets/2026-02-15-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
0
assets/images/reviews/timesheets/2026-02-22-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
0
assets/images/reviews/timesheets/2026-03-01-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
0
assets/images/reviews/timesheets/2026-03-08-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
0
assets/images/reviews/timesheets/2026-05-10-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
0
assets/images/reviews/timesheets/2026-05-17-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
0
assets/images/reviews/timesheets/2026-05-24-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
0
assets/images/reviews/timesheets/2026-05-31-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
32
assets/scripts/application/theme-switcher.js
Executable 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
8
assets/scripts/bigger-picture.min.js
vendored
@@ -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();
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
|
|||||||
196
assets/scripts/features/comments.js
Executable 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();
|
||||||
655
assets/scripts/features/hidden-details.js
Executable 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)}¤t=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) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'"
|
||||||
|
}[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));
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -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 they’re 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(); // don’t 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
@@ -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)}¤t=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) => ({
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
'"': """,
|
|
||||||
"'": "'"
|
|
||||||
}[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));
|
|
||||||
});
|
|
||||||
}());
|
}());
|
||||||
|
|||||||
2817
assets/scripts/mermaid.min.js
vendored
@@ -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, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
})();
|
|
||||||
|
|||||||
242
assets/scripts/pages/competency-status-board.js
Executable 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();
|
||||||
|
}
|
||||||
313
assets/scripts/pages/home-dashboard.js
Executable 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escAttr(str) {
|
||||||
|
return escHtml(str).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -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)];
|
||||||
|
}
|
||||||
|
})();
|
||||||
261
assets/scripts/pages/resource-loader-model.js
Executable 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
908
assets/scripts/pages/resource-loader.js
Executable 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
})();
|
||||||
398
assets/scripts/pages/sitemap-interactive.js
Executable 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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
1789
assets/scripts/pages/wird-tracker.js
Executable file
871
assets/scripts/play.js
Normal file → Executable 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)];
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
@@ -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 (h2–h4 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 mid‑page)
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
34
assets/scripts/svg-pan-zoom.min.js
vendored
19
assets/scripts/ui/copy-buttons.js
Executable 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
@@ -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
|
||||||
|
});
|
||||||
50
assets/scripts/ui/footnote-sidenotes.js
Executable 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
@@ -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 they’re 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(); // don’t 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
101
assets/scripts/ui/mermaid-diagrams.js
Executable 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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
88
assets/scripts/ui/toc-active-link.js
Executable 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 (h2–h4 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 mid‑page)
|
||||||
|
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
3475
assets/scripts/vendor/lunr.js
vendored
Executable file
2811
assets/scripts/vendor/mermaid.min.js
vendored
Executable file
22
assets/scripts/vendor/pdf.min.js
vendored
Executable file
22
assets/scripts/vendor/pdf.worker.min.js
vendored
Executable file
177
assets/scripts/vendor/pdfjs-LICENSE.txt
vendored
Executable 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
@@ -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, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escAttr(str) {
|
|
||||||
return escHtml(str).replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
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
@@ -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); }
|
||||||
|
|
||||||
|
|
||||||
9
assets/styles/bigger-picture.min.css
vendored
@@ -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 */
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
118
assets/styles/components/comments.css
Executable 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;
|
||||||
|
}
|
||||||
251
assets/styles/components/kanban.css
Executable 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);
|
||||||
|
}
|
||||||
76
assets/styles/components/mermaid.css
Executable 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);
|
||||||
|
}
|
||||||
281
assets/styles/components/notes-board.css
Executable 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
503
assets/styles/components/org-content.css
Executable 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);
|
||||||
|
}
|
||||||
95
assets/styles/components/org-syntax.css
Executable 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;
|
||||||
|
}
|
||||||
289
assets/styles/features/hidden-details.css
Executable 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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
162
assets/styles/layout/site-shell.css
Executable 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
649
assets/styles/pages/home-dashboard.css
Executable 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
@@ -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);
|
||||||
|
}
|
||||||
423
assets/styles/pages/resource-loader.css
Executable 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; }
|
||||||
|
}
|
||||||
1888
assets/styles/pages/wird-tracker.css
Executable file
833
assets/styles/play.css
Normal file → Executable 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
134
assets/styles/utilities/responsive.css
Executable 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||