Web dashboard v2 step 2: Tailwind design system, theme toggle, reader pages
Replaces the hand-written stylesheet with a Tailwind CSS v4 source (src/web/tailwind.css) whose minified build is committed as src/web/static/app.css and still embedded with include_str!, so the binary and the test suite never need Node. package.json pins tailwindcss and @tailwindcss/cli to 4.3.3 and adds css, css:watch and css:check. Colours are semantic CSS variables swapped per theme and exposed to Tailwind through @theme inline, so templates write bg-paper/text-ink/border-rule once and both themes work. theme.js runs synchronously in <head> to set data-theme before paint; app.js cycles the toggle system -> light -> dark and persists the choice. Newsreader is self-hosted and served from web::static_asset with the same ETag/304 logic as the other assets. Every reader template is restyled: ears row, centred masthead with its rules, tracked section labels, the drop cap on The Brief, "Why it's here" with its accent bar, the segmented rating control, prev/next cards, and the colophon as a two-column definition grid. Dashboard templates are untouched (step 3). Also fixes a .block collision with Tailwind's display utility, the SVG `hidden` property that never toggled the theme icon, and points the dev seed's profile_path at ./dev so previewing cannot rewrite data/profile.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
@@ -111,6 +111,13 @@ cargo test # everything is offline; no keys needed
|
|||||||
sudo install -m0755 target/release/daily-epub /usr/local/bin/
|
sudo install -m0755 target/release/daily-epub /usr/local/bin/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The web UI uses Tailwind CSS v4, but Node is only a development dependency: the
|
||||||
|
compiled stylesheet is committed and embedded in the Rust binary. After editing
|
||||||
|
`src/web/tailwind.css`, a web template, or `src/web/static/app.js`, run
|
||||||
|
`npm install` once and then `npm run css`. Commit both the source changes and
|
||||||
|
`src/web/static/app.css`; `npm run css:check` verifies that the committed output
|
||||||
|
is current.
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Step 2 handoff — Tailwind design system, theme toggle, reader pages
|
||||||
|
|
||||||
|
Branch `v2-frontend`, worktree `/home/thallada/workspace/the-daily-epub-v2b`, one commit.
|
||||||
|
Step 3 (dashboard restyle) and step 4 (TOC sidebar) build on what is described below.
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
### Toolchain
|
||||||
|
|
||||||
|
- `package.json` (root, `private`) pins `tailwindcss` and `@tailwindcss/cli` to `4.3.3` with
|
||||||
|
scripts `css`, `css:watch` and `css:check`. `package-lock.json` is committed.
|
||||||
|
`.gitignore` already carried `node_modules/` and `dev/`.
|
||||||
|
- `src/web/tailwind.css` is the single source; `src/web/static/app.css` is the **committed,
|
||||||
|
minified build** and is still embedded with `include_str!`, so `cargo build`, `cargo test`
|
||||||
|
and the deployed binary never need Node.
|
||||||
|
- `npm run css:check` rebuilds to a temp file and `diff -u`s it against the committed
|
||||||
|
`app.css`, exiting non-zero on drift (verified in both directions).
|
||||||
|
- Workflow documented in `README.md` and `docs/runbooks/web-dashboard-rollout.md`
|
||||||
|
(`npm ci && npm run css:check` added to the rollout build step).
|
||||||
|
|
||||||
|
### Assets
|
||||||
|
|
||||||
|
- `web::static_asset` gained fixed match arms (no path joins) for `theme.js`,
|
||||||
|
`Newsreader.woff2` and `Newsreader-italic.woff2`. The asset tuple is now
|
||||||
|
`(&str, &'static [u8])` so text assets use `include_str!(...).as_bytes()` and the fonts use
|
||||||
|
`include_bytes!`; the SHA-256 ETag / 304 / long-cache path is unchanged and its test still
|
||||||
|
passes. URLs are flat: `/static/Newsreader.woff2`, not `/static/fonts/…`.
|
||||||
|
- `@font-face` for both faces (`font-weight: 200 800`, `font-stretch: normal`,
|
||||||
|
`font-display: swap`, Google's latin `unicode-range`), plus a
|
||||||
|
`<link rel="preload" as="font" type="font/woff2" crossorigin>` for the regular face.
|
||||||
|
`document.fonts.check('600 48px Newsreader')` is true on a loaded page and the server log
|
||||||
|
shows no 404s.
|
||||||
|
|
||||||
|
### Theme
|
||||||
|
|
||||||
|
- `src/web/static/theme.js` runs synchronously in `<head>` (external file, CSP-safe): reads
|
||||||
|
`localStorage.theme`, sets or removes `document.documentElement.dataset.theme` before paint.
|
||||||
|
- `app.js` drives `[data-theme-toggle]`: cycles **system → light → dark → system**, persists
|
||||||
|
(`removeItem` for system), updates the attribute, the SVG icon and the visible label, keeps
|
||||||
|
`aria-label="Theme: <state>"`, and re-renders on `matchMedia` changes while in system mode.
|
||||||
|
- Colours are semantic CSS variables on `:root`, duplicated into
|
||||||
|
`@media (prefers-color-scheme: dark) :root:not([data-theme="light"])` and
|
||||||
|
`:root[data-theme="dark"]`, exposed to Tailwind via `@theme inline`, with
|
||||||
|
`@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *))`.
|
||||||
|
`color-scheme` follows the theme.
|
||||||
|
|
||||||
|
### Reader templates
|
||||||
|
|
||||||
|
`layout.html` (ears row + centred masthead with the 1px rule above and the 3px double rule
|
||||||
|
below + primary nav with `aria-current="page"` + admin nav row + flash + `<main id="content">`
|
||||||
|
+ footer + skip link), `issue_public.html`, `issue_full.html`, `issue_list.html`,
|
||||||
|
`article.html`, `world.html`, `behind.html`, `login.html`, `account.html`, `error.html` and
|
||||||
|
`_rating_widget.html` are all restyled with utilities. Every hook listed in the shared brief
|
||||||
|
(`form.rating`, `button[data-label]`, `.active`, `aria-pressed`, `form[data-confirm]`,
|
||||||
|
`details[id]`, `table[data-filter]`, `.table-filter`, `[data-refresh]`, `.scroll-x`, ids,
|
||||||
|
names, actions) is intact.
|
||||||
|
|
||||||
|
## What step 3 can use
|
||||||
|
|
||||||
|
Tokens (`@theme inline`, so both a CSS variable and a Tailwind colour utility exist):
|
||||||
|
|
||||||
|
| token | utilities |
|
||||||
|
| --- | --- |
|
||||||
|
| `--paper`, `--paper-2` | `bg-paper`, `bg-paper-2` |
|
||||||
|
| `--ink`, `--ink-2`, `--muted` | `text-ink`, `text-ink-2`, `text-muted`, `bg-ink` |
|
||||||
|
| `--rule`, `--rule-strong` | `border-rule`, `border-rule-strong` |
|
||||||
|
| `--accent`, `--accent-hover` | `text-accent`, `border-accent`, `bg-accent`, `hover:text-accent-hover` |
|
||||||
|
| `--loved`, `--good`, `--down`, `--warn` | `text-loved`, `text-good`, `text-down`, `text-warn` |
|
||||||
|
| `--font-serif` / `--font-sans` / `--font-mono` | `font-serif`, `font-sans`, `font-mono` |
|
||||||
|
|
||||||
|
Component classes in `@layer components`: `.btn`, `.btn-primary`, `.badge` + state modifiers
|
||||||
|
(`selected loved good down excluded shortlisted admitted eligible cleared assessed triaged
|
||||||
|
reason running requested ok degraded failed dry_run`), `.kv`, `.scroll-x`, `.prose-body`,
|
||||||
|
`.rating`/`.rating-prompt`/`.rating-note`, `.flash`, `.error`, `.notice`, `.dashboard`,
|
||||||
|
`.cards`/`.card`, `.muted`/`.meta`/`.num`, `.filters`, `.table-filter`, `.pager`/`.run-nav`/
|
||||||
|
`.tabs`, `pre.block`/`pre.preview`/`pre.journal`, `.funnel`, `.spark` (+ `.s0`–`.s5`, `.line`).
|
||||||
|
`@layer base` styles `table`/`thead`/`th`/`td` (sticky head on `bg-paper`, hairline rules,
|
||||||
|
`hover:bg-paper-2`, tabular numerals) and all form controls.
|
||||||
|
|
||||||
|
Repeated inline recipes step 3 will probably want: small label
|
||||||
|
`font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted`; headline
|
||||||
|
`font-semibold leading-[1.1] tracking-[-0.01em]`; reader measure
|
||||||
|
`mx-auto max-w-[68ch] px-4 sm:px-6`; dashboard shell `.dashboard` (`max-w-7xl`, sans).
|
||||||
|
|
||||||
|
## Deviations and fixes beyond the brief
|
||||||
|
|
||||||
|
- **`.block` collided with Tailwind's `block` display utility.** The legacy component was
|
||||||
|
renamed to `pre.block`, which keeps both `<pre class="block">` dashboard usages working and
|
||||||
|
frees `class="block"` for real layout. Before this, the article prev/next cards rendered as
|
||||||
|
grey monospace boxes.
|
||||||
|
- **`icon.hidden = …` does not work on SVG elements** (`hidden` is an `HTMLElement` IDL
|
||||||
|
attribute); the theme icon never changed. `app.js` now uses
|
||||||
|
`icon.toggleAttribute("hidden", …)`. `[hidden] { display:none !important }` stays in
|
||||||
|
`@layer base` so it beats display utilities.
|
||||||
|
- `.prose-body` lists had no markers after Preflight; added `list-disc` / `list-decimal` and a
|
||||||
|
muted `::marker`.
|
||||||
|
- The flash bar was flush to the viewport edge while the header used a gutter; `.flash` lost
|
||||||
|
its own `mx-auto max-w-7xl` and the layout wraps it in `mx-auto max-w-7xl px-4 sm:px-6`.
|
||||||
|
- The article prev/next grid rendered an empty first cell when there is no previous article;
|
||||||
|
the placeholder `<span>` is gone and the next card takes `sm:col-start-2`.
|
||||||
|
- `examples/seed_dev_db.rs` now writes `profile_path` into the throwaway `./dev` directory and
|
||||||
|
seeds it from `data/profile.md`. Without this, saving on `/dashboard/profile` while running
|
||||||
|
the dev server **rewrites the repository's `data/profile.md`** and breaks
|
||||||
|
`curate::profile::tests::shipped_profile_and_opml_parse`. It also fixes a pre-existing
|
||||||
|
`clippy::manual_is_multiple_of` error in that example (`cargo clippy --all-targets
|
||||||
|
-D warnings` did not pass on this branch before).
|
||||||
|
- `Page::new` for the Jobs and Users dashboard pages now passes `"jobs"` / `"users"` instead of
|
||||||
|
`"dashboard"`, so the admin nav highlights the right entry.
|
||||||
|
- `FullEntry` gained `is_lead` so the full issue's lead story is set larger (`text-3xl` vs
|
||||||
|
`text-2xl`), matching what `issue_public.html` already did.
|
||||||
|
- Fonts are served flat from `/static/<name>.woff2` (the brief allowed either shape).
|
||||||
|
- `issue_public.html` carries `{# step 1 adds summary/why here #}` at the point where the
|
||||||
|
backend step's summary/`why` markup should go; use the same treatment as `issue_full.html`
|
||||||
|
(summary as a body-size serif paragraph, then `border-l-2 border-accent pl-3 italic`).
|
||||||
|
- The dashboard templates are deliberately unstyled beyond what the base/component layers give
|
||||||
|
them. They render without errors (all nine routes smoke-tested) but look plain — step 3.
|
||||||
|
- The archive month label is still the raw `YYYY-MM` produced by `web::public`; changing it is
|
||||||
|
a backend concern and was left alone.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npm run css` / `npm run css:check` — clean; drift is detected (exit 1) when `app.css` is
|
||||||
|
edited by hand.
|
||||||
|
- `cargo fmt`, `cargo clippy --all-targets -- -D warnings` — clean.
|
||||||
|
- `cargo test` — **475 passed, 0 failed** (439 lib + 9 + 2 + 3 + 4 + 7 + 9 + 2 integration,
|
||||||
|
one binary with 0 tests). Run outside the Codex sandbox, so the socket-binding tests ran too.
|
||||||
|
No test needed a cosmetic-class update.
|
||||||
|
- Seeded dev server on `127.0.0.1:3599`. All of `/`, `/issues`, `/issues/2026-09-01` (legacy),
|
||||||
|
`/issues/2026-09-02`, `/issues/2026-09-02/articles/1`, `/…/world`, `/…/behind`, `/account`,
|
||||||
|
`/feed.xml` and the nine `/dashboard/*` routes return 200 with **no console errors and no
|
||||||
|
asset 404s**; `/issues/1999-01-01` returns the styled 404 page.
|
||||||
|
- Theme toggle asserted with Playwright: system → light → dark → system, `localStorage.theme`
|
||||||
|
written/removed, `data-theme` set/removed, icon and label follow, state survives a reload.
|
||||||
|
- Focus order checked: the skip link is the first tab stop and becomes visible; the toggle
|
||||||
|
shows the accent focus ring.
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/e963db53-510f-4312-ac87-460291a92781/scratchpad/step2-shots/`
|
||||||
|
— 83 PNGs. `/`, `/issues`, `/issues/2026-09-02`, `/issues/2026-09-02/articles/1`, `/…/world`,
|
||||||
|
`/…/behind`, `/account` and `/login` × {anonymous, reader, admin} × {light, dark} ×
|
||||||
|
{1280px desktop, 390px mobile}, full page. Named
|
||||||
|
`<path>-<user>-<theme>[-mobile].png`. Plus `toggle-1..4.png` (the toggle cycle),
|
||||||
|
`flash-light/dark.png`, `rating-active-light.png`, `focus-skiplink.png`, `focus-toggle.png`
|
||||||
|
and `issues_1999-01-01-none-{light,dark}.png` (404 page).
|
||||||
|
|
||||||
|
## Left for later
|
||||||
|
|
||||||
|
- Step 1 adds `summary`/`why` to the public entries (marker comment in place).
|
||||||
|
- Step 3 restyles `src/web/templates/dashboard/*` on top of this system.
|
||||||
|
- Step 4 adds the collapsing table-of-contents sidebar to the signed-in issue pages.
|
||||||
@@ -6,7 +6,15 @@ already be complete.
|
|||||||
|
|
||||||
## 1. Build and install
|
## 1. Build and install
|
||||||
|
|
||||||
|
The deployed binary does not require Node. For UI development, edit
|
||||||
|
`src/web/tailwind.css`, the templates, or `src/web/static/app.js`, then run
|
||||||
|
`npm run css` and commit the regenerated `src/web/static/app.css` alongside the
|
||||||
|
source. CI and release preparation should run `npm run css:check` to detect
|
||||||
|
stylesheet drift.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run css:check
|
||||||
cargo test
|
cargo test
|
||||||
cargo build --release
|
cargo build --release
|
||||||
sudo install -m0755 target/release/daily-epub /usr/local/bin/daily-epub
|
sudo install -m0755 target/release/daily-epub /usr/local/bin/daily-epub
|
||||||
@@ -134,4 +142,3 @@ Candidate and assessment history is pruned after
|
|||||||
`curation.ranking.telemetry_retention_days` (180 by default). Raise it now if the dashboard
|
`curation.ranking.telemetry_retention_days` (180 by default). Raise it now if the dashboard
|
||||||
should retain a longer article/run history, then reload `/dashboard/settings` and verify the
|
should retain a longer article/run history, then reload `/dashboard/settings` and verify the
|
||||||
effective value. This changes future pruning only; it cannot restore rows already deleted.
|
effective value. This changes future pruning only; it cannot restore rows already deleted.
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -116,7 +116,7 @@ fn story_article(index: usize, story: &(&str, &str, &str, &str, &str, i64)) -> A
|
|||||||
source.feed_id = article.feed_id;
|
source.feed_id = article.feed_id;
|
||||||
}
|
}
|
||||||
article.content_html = body_html(title, words);
|
article.content_html = body_html(title, words);
|
||||||
if index % 3 != 0 {
|
if !index.is_multiple_of(3) {
|
||||||
article.social.clear();
|
article.social.clear();
|
||||||
}
|
}
|
||||||
if index == 4 {
|
if index == 4 {
|
||||||
@@ -278,13 +278,23 @@ async fn seed_issue(db: &Db, issue: &mut Issue, with_snapshot: bool) -> anyhow::
|
|||||||
fn write_config(dir: &Path) -> anyhow::Result<PathBuf> {
|
fn write_config(dir: &Path) -> anyhow::Result<PathBuf> {
|
||||||
let path = dir.join("config.toml");
|
let path = dir.join("config.toml");
|
||||||
let dir = dir.canonicalize()?;
|
let dir = dir.canonicalize()?;
|
||||||
|
// The profile lives inside the throwaway directory: saving it from
|
||||||
|
// /dashboard/profile must never rewrite the repository's data/profile.md.
|
||||||
|
let profile = dir.join("profile.md");
|
||||||
let toml = format!(
|
let toml = format!(
|
||||||
"database_path = \"{db}\"\n\n[publish]\nepub_dir = \"{epubs}\"\nxtc_dir = \"{xtc}\"\n\n[server]\nbind = \"127.0.0.1:3599\"\npublic_url = \"http://127.0.0.1:3599\"\njobs_enabled = false\n",
|
"database_path = \"{db}\"\nprofile_path = \"{profile}\"\n\n[publish]\nepub_dir = \"{epubs}\"\nxtc_dir = \"{xtc}\"\n\n[server]\nbind = \"127.0.0.1:3599\"\npublic_url = \"http://127.0.0.1:3599\"\njobs_enabled = false\n",
|
||||||
db = dir.join("daily-epub.db").display(),
|
db = dir.join("daily-epub.db").display(),
|
||||||
|
profile = profile.display(),
|
||||||
epubs = dir.join("epubs").display(),
|
epubs = dir.join("epubs").display(),
|
||||||
xtc = dir.join("xtc").display(),
|
xtc = dir.join("xtc").display(),
|
||||||
);
|
);
|
||||||
std::fs::write(&path, toml)?;
|
std::fs::write(&path, toml)?;
|
||||||
|
if !profile.exists() {
|
||||||
|
let shipped = Path::new("data/profile.md");
|
||||||
|
let body = std::fs::read_to_string(shipped)
|
||||||
|
.unwrap_or_else(|_| "# Reader profile\n\n## Who he is\n\nA developer.\n".to_string());
|
||||||
|
std::fs::write(&profile, body)?;
|
||||||
|
}
|
||||||
std::fs::create_dir_all(dir.join("epubs"))?;
|
std::fs::create_dir_all(dir.join("epubs"))?;
|
||||||
std::fs::create_dir_all(dir.join("xtc"))?;
|
std::fs::create_dir_all(dir.join("xtc"))?;
|
||||||
Ok(path)
|
Ok(path)
|
||||||
|
|||||||
Generated
+313
@@ -0,0 +1,313 @@
|
|||||||
|
{
|
||||||
|
"name": "the-daily-epub-v2b",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/cli": "4.3.3",
|
||||||
|
"tailwindcss": "4.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
|
"version": "0.3.13",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/remapping": {
|
||||||
|
"version": "2.3.5",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.31",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^1.0.3",
|
||||||
|
"is-glob": "^4.0.3",
|
||||||
|
"micromatch": "^4.0.5",
|
||||||
|
"node-addon-api": "^7.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-x64-musl": "2.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher/node_modules/detect-libc": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"detect-libc": "bin/detect-libc.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/cli": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@parcel/watcher": "2.5.1",
|
||||||
|
"@tailwindcss/node": "4.3.3",
|
||||||
|
"@tailwindcss/oxide": "4.3.3",
|
||||||
|
"enhanced-resolve": "^5.24.1",
|
||||||
|
"mri": "^1.2.0",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"tailwindcss": "4.3.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tailwindcss": "dist/index.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/node": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
|
"enhanced-resolve": "^5.24.1",
|
||||||
|
"jiti": "^2.7.0",
|
||||||
|
"lightningcss": "1.32.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"source-map-js": "^1.2.1",
|
||||||
|
"tailwindcss": "4.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
||||||
|
"dev": true,
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": "4.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/braces": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"fill-range": "^7.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/enhanced-resolve": {
|
||||||
|
"version": "5.24.2",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.4",
|
||||||
|
"tapable": "^2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fill-range": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"to-regex-range": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/is-extglob": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/is-glob": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"is-extglob": "^2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-number": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||||
|
"lightningcss-linux-x64-musl": "1.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mri": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/picocolors": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/tailwindcss": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/tapable": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/to-regex-range": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"is-number": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"css": "tailwindcss -i src/web/tailwind.css -o src/web/static/app.css --minify",
|
||||||
|
"css:watch": "tailwindcss -i src/web/tailwind.css -o src/web/static/app.css --watch",
|
||||||
|
"css:check": "tmp=$(mktemp) && trap 'rm -f \"$tmp\"' EXIT && tailwindcss -i src/web/tailwind.css -o \"$tmp\" --minify && diff -u src/web/static/app.css \"$tmp\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/cli": "4.3.3",
|
||||||
|
"tailwindcss": "4.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,7 +129,7 @@ async fn jobs_template(
|
|||||||
) -> Result<JobsTemplate, WebError> {
|
) -> Result<JobsTemplate, WebError> {
|
||||||
let config = state.config();
|
let config = state.config();
|
||||||
let rows = jobs::list(&state.db, TABLE_ROWS).await.map_err(db_err)?;
|
let rows = jobs::list(&state.db, TABLE_ROWS).await.map_err(db_err)?;
|
||||||
let mut page = Page::new("Jobs", viewer, "dashboard");
|
let mut page = Page::new("Jobs", viewer, "jobs");
|
||||||
page.flash = flash;
|
page.flash = flash;
|
||||||
let today = config
|
let today = config
|
||||||
.tz()
|
.tz()
|
||||||
@@ -314,7 +314,7 @@ async fn show(
|
|||||||
Ok(log) => (log, None),
|
Ok(log) => (log, None),
|
||||||
Err(error) => (String::new(), Some(error)),
|
Err(error) => (String::new(), Some(error)),
|
||||||
};
|
};
|
||||||
let mut page = Page::new(format!("Job {id} · {}", row.name), viewer, "dashboard");
|
let mut page = Page::new(format!("Job {id} · {}", row.name), viewer, "jobs");
|
||||||
page.flash = take_flash(&session).await?;
|
page.flash = take_flash(&session).await?;
|
||||||
Ok(Html(JobTemplate {
|
Ok(Html(JobTemplate {
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ async fn index(
|
|||||||
open_sessions: row.open_sessions,
|
open_sessions: row.open_sessions,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut page = Page::new("Users", viewer, "dashboard");
|
let mut page = Page::new("Users", viewer, "users");
|
||||||
page.flash = take_flash(&session).await?;
|
page.flash = take_flash(&session).await?;
|
||||||
Ok(Html(UsersTemplate { page, users }))
|
Ok(Html(UsersTemplate { page, users }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ struct FullEntry {
|
|||||||
href: String,
|
href: String,
|
||||||
source: String,
|
source: String,
|
||||||
reading_minutes: i64,
|
reading_minutes: i64,
|
||||||
|
is_lead: bool,
|
||||||
summary: String,
|
summary: String,
|
||||||
why: Option<String>,
|
why: Option<String>,
|
||||||
rating: Option<RatingWidget>,
|
rating: Option<RatingWidget>,
|
||||||
@@ -351,6 +352,7 @@ pub async fn render_full(
|
|||||||
href: article_href(date, pick.article.id),
|
href: article_href(date, pick.article.id),
|
||||||
source: pick.article.feed_title.clone(),
|
source: pick.article.feed_title.clone(),
|
||||||
reading_minutes: pick.article.reading_minutes(),
|
reading_minutes: pick.article.reading_minutes(),
|
||||||
|
is_lead: pick.is_lead,
|
||||||
summary: summary_for(&view.issue, pick)
|
summary: summary_for(&view.issue, pick)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
|||||||
+23
-5
@@ -502,16 +502,34 @@ async fn static_asset(
|
|||||||
axum::extract::Path(file): axum::extract::Path<String>,
|
axum::extract::Path(file): axum::extract::Path<String>,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let asset = match file.as_str() {
|
let asset: (&str, &'static [u8]) = match file.as_str() {
|
||||||
"app.css" => ("text/css; charset=utf-8", include_str!("static/app.css")),
|
"app.css" => (
|
||||||
|
"text/css; charset=utf-8",
|
||||||
|
include_str!("static/app.css").as_bytes(),
|
||||||
|
),
|
||||||
"app.js" => (
|
"app.js" => (
|
||||||
"application/javascript; charset=utf-8",
|
"application/javascript; charset=utf-8",
|
||||||
include_str!("static/app.js"),
|
include_str!("static/app.js").as_bytes(),
|
||||||
|
),
|
||||||
|
"theme.js" => (
|
||||||
|
"application/javascript; charset=utf-8",
|
||||||
|
include_str!("static/theme.js").as_bytes(),
|
||||||
|
),
|
||||||
|
"favicon.svg" => (
|
||||||
|
"image/svg+xml",
|
||||||
|
include_str!("static/favicon.svg").as_bytes(),
|
||||||
|
),
|
||||||
|
"Newsreader.woff2" => (
|
||||||
|
"font/woff2",
|
||||||
|
include_bytes!("static/fonts/Newsreader.woff2"),
|
||||||
|
),
|
||||||
|
"Newsreader-italic.woff2" => (
|
||||||
|
"font/woff2",
|
||||||
|
include_bytes!("static/fonts/Newsreader-italic.woff2"),
|
||||||
),
|
),
|
||||||
"favicon.svg" => ("image/svg+xml", include_str!("static/favicon.svg")),
|
|
||||||
_ => return WebError::NotFound.into_response(),
|
_ => return WebError::NotFound.into_response(),
|
||||||
};
|
};
|
||||||
let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1.as_bytes())));
|
let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1)));
|
||||||
if headers
|
if headers
|
||||||
.get(header::IF_NONE_MATCH)
|
.get(header::IF_NONE_MATCH)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
|
|||||||
+2
-157
File diff suppressed because one or more lines are too long
@@ -1,3 +1,44 @@
|
|||||||
|
const themeToggle = document.querySelector("[data-theme-toggle]");
|
||||||
|
if (themeToggle) {
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const readTheme = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem("theme");
|
||||||
|
return saved === "light" || saved === "dark" ? saved : "system";
|
||||||
|
} catch (_) {
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const renderTheme = (theme) => {
|
||||||
|
themeToggle.setAttribute("aria-label", `Theme: ${theme}`);
|
||||||
|
themeToggle.querySelector("[data-theme-label]").textContent = theme[0].toUpperCase() + theme.slice(1);
|
||||||
|
themeToggle.querySelectorAll("[data-theme-icon]").forEach((icon) => {
|
||||||
|
// SVGElement has no `hidden` IDL attribute; toggle the content attribute.
|
||||||
|
icon.toggleAttribute("hidden", icon.getAttribute("data-theme-icon") !== theme);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const setTheme = (theme) => {
|
||||||
|
if (theme === "system") document.documentElement.removeAttribute("data-theme");
|
||||||
|
else document.documentElement.dataset.theme = theme;
|
||||||
|
try {
|
||||||
|
if (theme === "system") localStorage.removeItem("theme");
|
||||||
|
else localStorage.setItem("theme", theme);
|
||||||
|
} catch (_) {}
|
||||||
|
renderTheme(theme);
|
||||||
|
};
|
||||||
|
renderTheme(readTheme());
|
||||||
|
themeToggle.addEventListener("click", () => {
|
||||||
|
const current = readTheme();
|
||||||
|
setTheme(current === "system" ? "light" : current === "light" ? "dark" : "system");
|
||||||
|
});
|
||||||
|
media.addEventListener("change", () => {
|
||||||
|
if (readTheme() === "system") {
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
renderTheme("system");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("submit", (event) => {
|
document.addEventListener("submit", (event) => {
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
if (form.matches("form.rating") && event.submitter) {
|
if (form.matches("form.rating") && event.submitter) {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
const theme = localStorage.getItem("theme");
|
||||||
|
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
|
||||||
|
else document.documentElement.removeAttribute("data-theme");
|
||||||
|
} catch (_) {
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@source "../web/templates";
|
||||||
|
@source "./static/app.js";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Newsreader";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200 800;
|
||||||
|
font-stretch: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/static/Newsreader.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Newsreader";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 200 800;
|
||||||
|
font-stretch: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/static/Newsreader-italic.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#7a7568;
|
||||||
|
--rule:#d8d2c4; --rule-strong:#1c1b18; --accent:#a3231f; --accent-hover:#7c1a16;
|
||||||
|
--loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#8a6d1f; color-scheme:light;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--paper:#151513; --paper-2:#1e1d1a; --ink:#ebe6da; --ink-2:#bfb9aa; --muted:#8f8a7d;
|
||||||
|
--rule:#3a3833; --rule-strong:#ebe6da; --accent:#ef8c82; --accent-hover:#f7aaa2;
|
||||||
|
--loved:#7fcb92; --good:#7fb8d8; --down:#ea8f84; --warn:#d9b45a; color-scheme:dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--paper:#151513; --paper-2:#1e1d1a; --ink:#ebe6da; --ink-2:#bfb9aa; --muted:#8f8a7d;
|
||||||
|
--rule:#3a3833; --rule-strong:#ebe6da; --accent:#ef8c82; --accent-hover:#f7aaa2;
|
||||||
|
--loved:#7fcb92; --good:#7fb8d8; --down:#ea8f84; --warn:#d9b45a; color-scheme:dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-paper:var(--paper); --color-paper-2:var(--paper-2); --color-ink:var(--ink);
|
||||||
|
--color-ink-2:var(--ink-2); --color-muted:var(--muted); --color-rule:var(--rule);
|
||||||
|
--color-rule-strong:var(--rule-strong); --color-accent:var(--accent);
|
||||||
|
--color-accent-hover:var(--accent-hover); --color-loved:var(--loved); --color-good:var(--good);
|
||||||
|
--color-down:var(--down); --color-warn:var(--warn);
|
||||||
|
--font-serif:"Newsreader", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||||
|
--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
[hidden] { display:none !important; }
|
||||||
|
html { @apply bg-paper text-ink antialiased; }
|
||||||
|
body { @apply m-0 min-h-screen bg-paper font-serif text-[1.0625rem] leading-[1.65] text-ink; font-optical-sizing:auto; }
|
||||||
|
::selection { background:color-mix(in oklab, var(--accent) 24%, transparent); }
|
||||||
|
a { @apply text-accent decoration-rule underline-offset-4 transition-colors duration-150 hover:text-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
|
||||||
|
h1, h2, h3 { @apply text-ink; font-optical-sizing:auto; }
|
||||||
|
button, input, select, textarea { font:inherit; @apply rounded-sm border border-rule bg-paper px-2.5 py-1.5 text-ink transition-colors duration-150 placeholder:text-muted focus:border-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
|
||||||
|
button { @apply cursor-pointer; }
|
||||||
|
textarea { @apply min-h-28; }
|
||||||
|
table { @apply w-full border-collapse font-sans text-sm leading-snug; font-variant-numeric:tabular-nums; }
|
||||||
|
thead { @apply sticky top-0 z-10 bg-paper; }
|
||||||
|
th, td { @apply border-b border-rule px-2 py-2 text-left align-top; }
|
||||||
|
th { @apply text-[0.72rem] font-semibold uppercase tracking-[0.1em] text-muted; }
|
||||||
|
tbody tr { @apply transition-colors duration-150 hover:bg-paper-2; }
|
||||||
|
code, pre { @apply font-mono; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior:auto !important; transition-duration:0.01ms !important; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn, .button { @apply inline-flex min-h-10 items-center justify-center rounded-sm border border-rule-strong bg-transparent px-3 py-1.5 font-sans text-sm font-medium text-ink no-underline transition-colors duration-150 hover:bg-ink hover:text-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
|
||||||
|
.btn-primary { @apply inline-flex min-h-10 items-center justify-center rounded-sm border border-ink bg-ink px-3 py-1.5 font-sans text-sm font-medium text-paper no-underline transition-colors duration-150 hover:border-accent hover:bg-accent hover:text-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
|
||||||
|
.badge { @apply inline-flex items-center rounded-full px-2 py-0.5 font-sans text-xs font-medium; }
|
||||||
|
.badge.selected, .badge.loved, .badge.ok { color:var(--loved); background:color-mix(in oklab, var(--loved) 14%, transparent); }
|
||||||
|
.badge.good, .badge.assessed, .badge.triaged, .badge.degraded { color:var(--good); background:color-mix(in oklab, var(--good) 14%, transparent); }
|
||||||
|
.badge.down, .badge.excluded, .badge.failed, .badge.dry_run { color:var(--down); background:color-mix(in oklab, var(--down) 14%, transparent); }
|
||||||
|
.badge.shortlisted, .badge.admitted, .badge.eligible, .badge.cleared, .badge.reason, .badge.running, .badge.requested { color:var(--muted); background:color-mix(in oklab, var(--muted) 14%, transparent); }
|
||||||
|
.kv { @apply grid grid-cols-[minmax(9rem,1fr)_2fr] gap-x-5 gap-y-1; }
|
||||||
|
.kv dt { @apply text-muted; }
|
||||||
|
.kv dd { @apply m-0 min-w-0; overflow-wrap:anywhere; }
|
||||||
|
.scroll-x { @apply max-w-full overflow-x-auto; overscroll-behavior-inline:contain; }
|
||||||
|
.scroll-x > table { min-width:max-content; }
|
||||||
|
.prose-body { @apply text-[1.0625rem] leading-[1.72] text-ink; }
|
||||||
|
.prose-body > * + * { @apply mt-5; }
|
||||||
|
.prose-body h2 { @apply mt-10 border-t border-rule pt-4 text-2xl font-semibold leading-[1.15] tracking-[-0.01em]; }
|
||||||
|
.prose-body h3 { @apply mt-8 text-xl font-semibold leading-tight; }
|
||||||
|
.prose-body a { @apply text-ink underline decoration-rule underline-offset-4 hover:text-ink hover:decoration-accent; }
|
||||||
|
.prose-body img { @apply mx-auto my-7 block h-auto max-w-full rounded-sm text-center font-sans text-xs italic text-muted; }
|
||||||
|
.prose-body pre { @apply max-w-full overflow-x-auto rounded-sm bg-paper-2 p-4 text-sm leading-relaxed; }
|
||||||
|
.prose-body :not(pre) > code { @apply rounded-sm bg-paper-2 px-1 py-0.5 text-[0.9em]; }
|
||||||
|
.prose-body blockquote { @apply my-7 border-l-2 border-rule-strong pl-5 text-ink-2; }
|
||||||
|
.prose-body ul { @apply list-disc pl-6; }
|
||||||
|
.prose-body ol { @apply list-decimal pl-6; }
|
||||||
|
.prose-body li::marker { color:var(--muted); }
|
||||||
|
.prose-body li + li { @apply mt-2; }
|
||||||
|
.rating { @apply my-5 flex flex-wrap items-center font-sans text-sm; }
|
||||||
|
.rating-prompt { @apply mr-3 text-[0.72rem] uppercase tracking-[0.1em] text-muted; }
|
||||||
|
.rating button[data-label]:not(.clear) { @apply -ml-px min-h-10 rounded-none border-rule bg-transparent px-3 py-1.5 text-ink first:ml-0 first:rounded-l-sm last:rounded-r-sm hover:z-10 hover:border-ink hover:bg-paper-2; }
|
||||||
|
.rating button[data-label]:not(.clear).active { @apply z-10 border-ink bg-ink text-paper; }
|
||||||
|
.rating button.clear { @apply ml-2 min-h-10 border-0 bg-transparent px-2 text-muted underline decoration-rule underline-offset-4 hover:text-ink; }
|
||||||
|
.rating button.clear.active { @apply font-semibold text-ink; }
|
||||||
|
.rating-note { @apply mt-2 flex basis-full items-center gap-2 text-muted; }
|
||||||
|
.rating-note input { @apply flex-1; }
|
||||||
|
.flash { @apply my-4 border-l-4 border-accent bg-paper-2 px-4 py-3 font-sans text-sm; }
|
||||||
|
.error { @apply border-l-4 border-down bg-paper-2 px-4 py-3 font-sans text-sm text-down; }
|
||||||
|
.notice { @apply border-l-4 border-warn bg-paper-2 px-4 py-3 font-sans text-sm text-ink-2; }
|
||||||
|
.dashboard { @apply mx-auto my-8 max-w-7xl px-4 font-sans text-base leading-normal sm:px-6; }
|
||||||
|
.dashboard h1 { @apply text-2xl font-semibold; } .dashboard h2 { @apply mt-7 text-lg font-semibold; } .dashboard h3 { @apply font-semibold; }
|
||||||
|
.cards { @apply my-4 grid gap-4; grid-template-columns:repeat(auto-fit,minmax(min(20rem,100%),1fr)); }
|
||||||
|
.card { @apply min-w-0 border border-rule p-4; }
|
||||||
|
.muted, .meta { @apply text-muted; } .num { @apply whitespace-nowrap text-right tabular-nums; }
|
||||||
|
.filters, form.filters, form.inline { @apply my-4 flex flex-wrap items-end gap-3 font-sans text-sm; }
|
||||||
|
.filters label, form.filters label { @apply grid gap-1; }
|
||||||
|
.table-filter { @apply my-2 w-80 max-w-full font-sans text-sm; }
|
||||||
|
.pager, .run-nav, .tabs { @apply my-4 flex flex-wrap items-center gap-4 font-sans text-sm; }
|
||||||
|
pre.block, pre.preview, pre.journal { @apply overflow-auto whitespace-pre-wrap rounded-sm bg-paper-2 p-3 font-mono text-sm; overflow-wrap:anywhere; }
|
||||||
|
.funnel > * { @apply my-0.5 min-w-px bg-good; }
|
||||||
|
.spark { @apply h-auto max-w-full; }
|
||||||
|
.spark .s0 { fill:var(--good); } .spark .s1 { fill:var(--loved); } .spark .s2 { fill:var(--down); } .spark .s3 { fill:var(--accent); } .spark .s4 { fill:var(--muted); } .spark .s5 { fill:var(--ink); } .spark .line { stroke:var(--accent); }
|
||||||
|
@media (max-width:40rem) {
|
||||||
|
.kv { @apply block; } .kv dt { @apply mt-2; }
|
||||||
|
.rating { @apply items-stretch; } .rating-prompt, .rating-note { @apply basis-full; } .rating-prompt { @apply mb-2; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{% extends "layout.html" %}{% block content %}<section class="reading narrow"><h1>Account</h1>{% if !error.is_empty() %}<p class="error">{{ error }}</p>{% endif %}<form method="post" action="/account/password"><label>Current password <input type="password" name="current_password" required></label><label>New password <input type="password" name="new_password" minlength="12" required></label><label>Confirm password <input type="password" name="confirm_password" minlength="12" required></label><button>Change password</button></form><form method="post" action="/account/logout-all" data-confirm="Sign out everywhere?"><button>Sign out everywhere</button></form><form method="post" action="/logout"><button>Sign out</button></form></section>{% endblock %}
|
{% extends "layout.html" %}{% block ears %}<span>Reader account</span>{% endblock %}{% block content %}<section class="mx-auto mt-12 max-w-md px-4 sm:mt-16 sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Reader account</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">Account</h1>{% if !error.is_empty() %}<p class="error mt-6">{{ error }}</p>{% endif %}<form class="mt-8 grid gap-5 border-b border-rule pb-9 font-sans text-sm" method="post" action="/account/password"><label class="grid gap-1.5 font-medium">Current password <input class="min-h-11 w-full" type="password" name="current_password" autocomplete="current-password" required></label><label class="grid gap-1.5 font-medium">New password <input class="min-h-11 w-full" type="password" name="new_password" autocomplete="new-password" minlength="12" required></label><label class="grid gap-1.5 font-medium">Confirm password <input class="min-h-11 w-full" type="password" name="confirm_password" autocomplete="new-password" minlength="12" required></label><button class="btn-primary mt-1 w-full">Change password</button></form><div class="mt-7 flex flex-wrap gap-3 font-sans text-sm"><form class="m-0" method="post" action="/account/logout-all" data-confirm="Sign out everywhere?"><button class="btn">Sign out everywhere</button></form><form class="m-0" method="post" action="/logout"><button class="btn">Sign out</button></form></div></section>{% endblock %}
|
||||||
|
|||||||
@@ -1,20 +1,6 @@
|
|||||||
{% extends "layout.html" %}{% block content %}<article class="reading article-page">
|
{% extends "layout.html" %}{% block ears %}<span>From this issue</span>{% endblock %}{% block content %}<article class="mx-auto mt-10 max-w-[68ch] px-4 sm:mt-14 sm:px-6">
|
||||||
<header class="article-header">
|
<header><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Article</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl"><a class="text-ink no-underline hover:text-accent" href="{{ source_url }}">{{ title }}</a></h1>{% match byline %}{% when Some with (byline) %}<p class="mt-5 font-sans text-sm font-medium text-ink-2">{{ byline }}</p>{% when None %}{% endmatch %}<p class="mt-1 font-sans text-sm text-muted">{{ meta_line }}</p>{% match why %}{% when Some with (why) %}<p class="mt-6 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match social_line %}{% when Some with (social) %}<p class="mt-4 font-sans text-sm text-muted">{{ social }}</p>{% when None %}{% endmatch %}{% match summary %}{% when Some with (summary) %}<p class="mt-7 text-xl italic leading-relaxed text-ink-2">{{ summary }}</p>{% when None %}{% endmatch %}{% if excerpt_only %}<p class="notice mt-6">Excerpt only — continue reading at the original site.</p>{% endif %}</header>
|
||||||
<h1><a href="{{ source_url }}">{{ title }}</a></h1>
|
<div class="prose-body mt-10 border-t border-rule pt-8">{{ body_html|safe }}</div>
|
||||||
{% match byline %}{% when Some with (byline) %}<p class="byline">{{ byline }}</p>{% when None %}{% endmatch %}
|
{% match discussion_html %}{% when Some with (discussion) %}<section class="discussion prose-body mt-12 border-t border-rule pt-6 [&_.comment-meta]:font-sans [&_.comment-meta]:text-sm [&_.comment-meta]:text-muted [&_blockquote.reply]:ml-6"><h2 class="mb-6 border-0 pt-0 text-2xl">Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %}
|
||||||
<p class="meta">{{ meta_line }}</p>
|
<footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6"><a class="btn" href="{{ read_online_url }}">Read online ↗</a></p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer>
|
||||||
{% match why %}{% when Some with (why) %}<p class="why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}
|
|
||||||
{% match social_line %}{% when Some with (social) %}<p class="social">{{ social }}</p>{% when None %}{% endmatch %}
|
|
||||||
{% match summary %}{% when Some with (summary) %}<p class="summary">{{ summary }}</p>{% when None %}{% endmatch %}
|
|
||||||
{% if excerpt_only %}<p class="notice">(excerpt only — read online)</p>{% endif %}
|
|
||||||
</header>
|
|
||||||
<hr class="rule">
|
|
||||||
<div class="article-body">{{ body_html|safe }}</div>
|
|
||||||
{% match discussion_html %}{% when Some with (discussion) %}<section class="discussion"><h2>Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %}
|
|
||||||
<footer class="article-footer">
|
|
||||||
{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}
|
|
||||||
<p><a href="{{ read_online_url }}">Read online ↗</a></p>
|
|
||||||
<nav class="prev-next">{% match previous %}{% when Some with (previous) %}<a rel="prev" href="{{ previous.href }}">← {{ previous.title }}</a>{% when None %}<span></span>{% endmatch %}{% match next %}{% when Some with (next) %}<a rel="next" href="{{ next.href }}">{{ next.title }} →</a>{% when None %}{% endmatch %}</nav>
|
|
||||||
<p><a href="{{ issue_href }}">← Back to this issue</a></p>
|
|
||||||
</footer>
|
|
||||||
</article>{% endblock %}
|
</article>{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1 @@
|
|||||||
{% extends "layout.html" %}{% block content %}<article class="reading behind">
|
{% extends "layout.html" %}{% block ears %}<span>Edition notes</span>{% endblock %}{% block content %}<article class="mx-auto mt-10 max-w-[68ch] px-4 sm:mt-14 sm:px-6"><header class="mb-9"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Edition notes</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">Behind the paper</h1></header><section class="border-y border-rule py-6"><p>{{ summary_line }}</p><p class="mt-3">{{ admitted_line }}</p><p class="mt-3">{{ learned_line }}</p></section><section class="mt-10"><h2 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">Near misses</h2><p class="mt-5 italic text-ink-2">Highest utility not selected.</p>{% if near_misses.is_empty() %}<p class="mt-4 text-muted">None recorded for this run.</p>{% else %}<ol class="mt-5 list-decimal space-y-3 pl-6">{% for near_miss in near_misses %}<li>{% if page.is_admin() %}<a class="text-ink decoration-rule hover:decoration-accent" href="/dashboard/articles/{{ near_miss.article_id }}">{{ near_miss.line }}</a>{% else %}{{ near_miss.line }}{% endif %}</li>{% endfor %}</ol>{% endif %}</section><p class="mt-10 border-l-2 border-rule pl-4 italic text-ink-2">{{ models_line }}</p><p class="mt-10 border-t border-rule pt-5 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></article>{% endblock %}
|
||||||
<h1>Behind the paper</h1>
|
|
||||||
<p class="fact-line">{{ summary_line }}</p><p class="fact-line">{{ admitted_line }}</p><p class="fact-line">{{ learned_line }}</p>
|
|
||||||
<h2>Near misses</h2><p><em>Highest utility not selected.</em></p>
|
|
||||||
{% if near_misses.is_empty() %}<p>None recorded for this run.</p>{% else %}<ul class="near-misses">{% for near_miss in near_misses %}<li>{% if page.is_admin() %}<a href="/dashboard/articles/{{ near_miss.article_id }}">{{ near_miss.line }}</a>{% else %}{{ near_miss.line }}{% endif %}</li>{% endfor %}</ul>{% endif %}
|
|
||||||
<p class="fact-line">{{ models_line }}</p><p><a href="{{ issue_href }}">← Back to this issue</a></p>
|
|
||||||
</article>{% endblock %}
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{% extends "layout.html" %}{% block content %}<section class="reading"><h1>{{ heading }}</h1><p>{{ message }}</p></section>{% endblock %}
|
{% extends "layout.html" %}{% block ears %}<span>Something went wrong</span>{% endblock %}{% block content %}<section class="mx-auto max-w-[68ch] px-4 py-20 text-center sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-down">Error</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">{{ heading }}</h1><p class="mt-5 text-lg text-ink-2">{{ message }}</p><p class="mt-8 font-sans text-sm"><a href="/">Return to the latest issue</a></p></section>{% endblock %}
|
||||||
|
|||||||
@@ -1,33 +1,11 @@
|
|||||||
{% extends "layout.html" %}{% block content %}<article class="reading issue full-issue">
|
{% extends "layout.html" %}{% block ears %}<span>{{ display_date }} <span class="hidden sm:inline">· No. {{ issue_number }}</span></span>{% endblock %}{% block content %}
|
||||||
<p class="dateline">{{ display_date }} · No. {{ issue_number }}</p>
|
<article class="mx-auto mt-8 max-w-[68ch] px-4 sm:mt-12 sm:px-6">
|
||||||
<p class="stats">{{ stats_line }}</p>
|
<header class="mb-8 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ display_date }} · No. {{ issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ stats_line }}</p></header>
|
||||||
<hr class="rule">
|
<section aria-labelledby="brief-heading"><h1 id="brief-heading" class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
|
||||||
<h1 class="kicker">The Brief</h1>
|
{% if !downloads.is_empty() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% for download in downloads %}<a class="btn" href="{{ download.href }}">Download {{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
|
||||||
<div class="editorial">{{ front_page_html|safe }}</div>
|
<section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2>
|
||||||
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a class="button" href="{{ download.href }}">Download {{ download.label }} <small>({{ download.size }})</small></a>{% endfor %}</p>{% endif %}
|
{% for section in sections %}<section class="mt-10"><h3 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>{% if !entry.summary.is_empty() %}<p class="mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
|
||||||
<hr class="rule">
|
</section>
|
||||||
<h1>In This Issue</h1>
|
{% if has_world || has_behind %}<nav class="my-12 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-y border-rule py-4 text-center font-sans text-sm uppercase tracking-[0.08em]" aria-label="Issue chapters">{% if has_world %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %}<span class="text-muted" aria-hidden="true">·</span>{% endif %}{% if has_behind %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %}
|
||||||
{% for section in sections %}<section><h2>{{ section.name }}</h2><ul class="index-list">{% for entry in section.entries %}<li class="index-entry">
|
<footer class="mt-12 border-t border-rule pt-2 font-sans text-sm leading-relaxed"><h2 class="text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{{ colophon.entries_fetched }} from {{ colophon.feeds_seen }} feeds</dd><dt>Candidates scored</dt><dd>{{ colophon.candidates }}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{{ colophon.cost_usd }}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>
|
||||||
<h3><a href="{{ entry.href }}">{{ entry.title }}</a></h3>
|
|
||||||
<p class="index-meta">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>
|
|
||||||
{% if !entry.summary.is_empty() %}<p class="index-summary">{{ entry.summary }}</p>{% endif %}
|
|
||||||
{% match entry.why %}{% when Some with (why) %}<p class="index-why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}
|
|
||||||
{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}
|
|
||||||
</li>{% endfor %}</ul></section>{% endfor %}
|
|
||||||
{% if has_world || has_behind %}<nav class="issue-chapters">{% if has_world %}<a href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %} · {% endif %}{% if has_behind %}<a href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %}
|
|
||||||
<footer class="colophon"><h2>Colophon</h2>
|
|
||||||
<p><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p>
|
|
||||||
<dl class="kv">
|
|
||||||
<dt>Generated</dt><dd>{{ colophon.generated_at }}</dd>
|
|
||||||
<dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd>
|
|
||||||
<dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd>
|
|
||||||
<dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd>
|
|
||||||
<dt>Entries considered</dt><dd>{{ colophon.entries_fetched }} from {{ colophon.feeds_seen }} feeds</dd>
|
|
||||||
<dt>Candidates scored</dt><dd>{{ colophon.candidates }}</dd>
|
|
||||||
<dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd>
|
|
||||||
<dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>
|
|
||||||
{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}
|
|
||||||
<dt>Total token cost</dt><dd>{{ colophon.cost_usd }}</dd>
|
|
||||||
<dt>Generator</dt><dd>{{ colophon.generator_version }}</dd>
|
|
||||||
</dl></footer>
|
|
||||||
</article>{% endblock %}
|
</article>{% endblock %}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{% extends "layout.html" %}{% block content %}<section class="reading"><h1>Issue archive</h1>{% for month in months %}<h2>{{ month.label }}</h2><ul>{% for issue in month.issues %}<li><a href="/issues/{{ issue.date }}">{{ issue.display_date }}</a> · No. {{ issue.issue_number }} · {{ issue.article_count }} articles</li>{% endfor %}</ul>{% endfor %}</section>{% endblock %}
|
{% extends "layout.html" %}{% block ears %}<span>Past editions</span>{% endblock %}{% block content %}<section class="mx-auto mt-10 max-w-[68ch] px-4 sm:mt-14 sm:px-6"><header class="mb-10"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Past editions</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">Issue archive</h1></header>{% for month in months %}<section class="mt-10"><h2 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ month.label }}</h2><ul class="m-0 list-none p-0">{% for issue in month.issues %}<li class="grid grid-cols-[1fr_auto] gap-x-4 border-b border-rule py-4"><a class="text-xl font-semibold leading-tight text-ink no-underline hover:text-accent" href="/issues/{{ issue.date }}">{{ issue.display_date }}</a><span class="font-sans text-sm tabular-nums text-muted">No. {{ issue.issue_number }}</span><span class="font-sans text-sm text-muted">{{ issue.article_count }} articles</span></li>{% endfor %}</ul></section>{% endfor %}</section>{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{% extends "layout.html" %}{% block content %}<article class="reading issue">
|
{% extends "layout.html" %}{% block ears %}{% if empty %}<span>Morning edition</span>{% else %}<span>{{ issue.display_date }} <span class="hidden sm:inline">· No. {{ issue.issue_number }}</span></span>{% endif %}{% endblock %}{% block content %}
|
||||||
{% if empty %}<h1>No issue yet</h1><p>The first issue has not been published.</p>{% else %}
|
<article class="mx-auto mt-8 max-w-[68ch] px-4 sm:mt-12 sm:px-6">
|
||||||
<p class="dateline">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="stats">{{ issue.stats_line }}</p>
|
{% if empty %}<div class="py-16 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Morning edition</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">No issue yet</h1><p class="mt-4 text-ink-2">The first issue has not been published.</p></div>{% else %}
|
||||||
<p class="strap">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p>
|
<header class="mb-10 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ issue.stats_line }}</p><p class="mx-auto mt-5 max-w-[58ch] text-lg italic leading-relaxed text-ink-2">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p></header>
|
||||||
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a href="{{ download.href }}">{{ download.label }} ({{ download.size }})</a>{% endfor %}</p>{% endif %}
|
{% if !downloads.is_empty() %}<div class="mb-8 flex flex-wrap justify-center gap-2">{% for download in downloads %}<a class="btn" href="{{ download.href }}">{{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
|
||||||
{% for section in issue.sections %}<section><h2>{{ section.name }}</h2>{% for entry in section.entries %}<article{% if entry.is_lead %} class="lead"{% endif %}><h3><a href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="byline">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }} ({{ entry.domain }}) · {{ entry.reading_minutes }} min · {{ entry.word_count }} words</p>{% if !entry.comment_links.is_empty() %}<p class="comments">{% for link in entry.comment_links %}<a rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</section>{% endfor %}
|
{% for section in issue.sections %}<section class="mt-10"><h2 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ section.name }}</h2><div>{% for entry in section.entries %}<article class="border-b border-rule py-6{% if entry.is_lead %} pt-5{% endif %}"><h3 class="font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="mt-2 font-sans text-sm leading-relaxed text-muted">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% if !entry.domain.is_empty() %} ({{ entry.domain }}){% endif %} · {{ entry.reading_minutes }} min</p>{# step 1 adds summary/why here #}{% if !entry.comment_links.is_empty() %}<p class="mt-3 flex flex-wrap gap-x-4 gap-y-1 font-sans text-sm text-muted">{% for link in entry.comment_links %}<a class="text-muted hover:text-accent" rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</div></section>{% endfor %}
|
||||||
<p><a href="/issues">Browse the archive</a></p>{% endif %}</article>{% endblock %}
|
<p class="mt-10 text-center font-sans text-sm"><a href="/issues">Browse the archive →</a></p>
|
||||||
|
{% endif %}</article>{% endblock %}
|
||||||
|
|||||||
@@ -4,17 +4,49 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{ page.title }} · The Daily EPUB</title>
|
<title>{{ page.title }} · The Daily EPUB</title>
|
||||||
|
<script src="/static/theme.js?v={{ page.version }}"></script>
|
||||||
|
<link rel="preload" href="/static/Newsreader.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/static/app.css?v={{ page.version }}">
|
<link rel="stylesheet" href="/static/app.css?v={{ page.version }}">
|
||||||
<link rel="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml">
|
<link rel="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml">
|
||||||
<link rel="icon" href="/static/favicon.svg">
|
<link rel="icon" href="/static/favicon.svg">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="masthead"><a href="/">The Daily EPUB</a></header>
|
<a class="fixed left-3 top-3 z-50 -translate-y-20 bg-ink px-3 py-2 font-sans text-sm text-paper no-underline focus:translate-y-0" href="#content">Skip to content</a>
|
||||||
<nav class="primary" aria-label="Site"><a href="/">Latest</a><a href="/issues">Archive</a><a href="/feed.xml">Feed</a>{% match page.viewer %}{% when Some with (viewer) %}<a href="/account">{{ viewer.username }}</a>{% when None %}<a href="/login">Sign in</a>{% endmatch %}</nav>
|
<header class="mx-auto max-w-7xl px-4 pt-4 sm:px-6 sm:pt-6">
|
||||||
{% if page.is_admin() %}<nav class="admin" aria-label="Dashboard"><a href="/dashboard">Overview</a><a href="/dashboard/runs">Runs</a><a href="/dashboard/articles">Articles</a><a href="/dashboard/ratings">Ratings</a><a href="/dashboard/profile">Profile</a><a href="/dashboard/stats">Stats</a><a href="/dashboard/jobs">Jobs</a><a href="/dashboard/settings">Settings</a><a href="/dashboard/users">Users</a></nav>{% endif %}
|
<div class="mb-2 flex min-h-8 items-center justify-between gap-4 border-b border-rule pb-2 font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">
|
||||||
{% match page.flash %}{% when Some with (flash) %}<div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div>{% when None %}{% endmatch %}
|
<div>{% block ears %}<span>Morning edition</span>{% endblock %}</div>
|
||||||
<main>{% block content %}{% endblock %}</main>
|
<div class="flex items-center gap-3 normal-case tracking-normal">
|
||||||
<footer>daily-epub {{ page.version }}</footer>
|
<button class="flex min-h-10 items-center gap-1.5 border-0 bg-transparent px-1.5 py-1 text-xs text-muted hover:text-ink" type="button" data-theme-toggle aria-label="Theme: system">
|
||||||
|
<svg data-theme-icon="system" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75"><path d="M4 5.5h16v11H4zM9 20h6M12 16.5V20"/></svg>
|
||||||
|
<svg data-theme-icon="light" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75" hidden><circle cx="12" cy="12" r="3.5"/><path d="M12 2v2.2M12 19.8V22M4.93 4.93l1.56 1.56M17.51 17.51l1.56 1.56M2 12h2.2M19.8 12H22M4.93 19.07l1.56-1.56M17.51 6.49l1.56-1.56"/></svg>
|
||||||
|
<svg data-theme-icon="dark" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75" hidden><path d="M20 15.1A8.5 8.5 0 0 1 8.9 4a8.5 8.5 0 1 0 11.1 11.1Z"/></svg>
|
||||||
|
<span data-theme-label>System</span>
|
||||||
|
</button>
|
||||||
|
{% match page.viewer %}{% when Some with (viewer) %}<a class="text-ink no-underline hover:text-accent" href="/account">{{ viewer.username }}</a>{% when None %}<a class="text-ink no-underline hover:text-accent" href="/login">Sign in</a>{% endmatch %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-b-[3px] border-double border-ink py-3 text-center sm:py-4"><a class="font-serif text-3xl font-semibold leading-none tracking-[-0.02em] text-ink no-underline hover:text-ink sm:text-5xl" href="/">The Daily EPUB</a></div>
|
||||||
|
<nav class="flex min-h-11 flex-wrap items-center justify-center gap-x-6 gap-y-1 border-b border-rule font-sans text-[0.72rem] font-medium uppercase tracking-[0.12em]" aria-label="Site">
|
||||||
|
<a class="flex min-h-11 items-center border-b-2 {% if page.active_nav == "latest" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %} no-underline hover:text-ink" href="/"{% if page.active_nav == "latest" %} aria-current="page"{% endif %}>Latest</a>
|
||||||
|
<a class="flex min-h-11 items-center border-b-2 {% if page.active_nav == "archive" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %} no-underline hover:text-ink" href="/issues"{% if page.active_nav == "archive" %} aria-current="page"{% endif %}>Archive</a>
|
||||||
|
<a class="flex min-h-11 items-center border-b-2 border-transparent text-muted no-underline hover:text-ink" href="/feed.xml">Feed</a>
|
||||||
|
{% match page.viewer %}{% when Some with (_) %}<a class="flex min-h-11 items-center border-b-2 {% if page.active_nav == "account" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %} no-underline hover:text-ink" href="/account"{% if page.active_nav == "account" %} aria-current="page"{% endif %}>Account</a>{% when None %}<a class="flex min-h-11 items-center border-b-2 {% if page.active_nav == "login" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %} no-underline hover:text-ink" href="/login"{% if page.active_nav == "login" %} aria-current="page"{% endif %}>Sign in</a>{% endmatch %}
|
||||||
|
</nav>
|
||||||
|
{% if page.is_admin() %}<nav class="flex flex-wrap items-center justify-center gap-x-4 border-b border-rule py-1 font-sans text-[0.68rem] uppercase tracking-[0.1em]" aria-label="Dashboard">
|
||||||
|
<a class="py-2 no-underline {% if page.active_nav == "dashboard" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard"{% if page.active_nav == "dashboard" %} aria-current="page"{% endif %}>Overview</a>
|
||||||
|
<a class="py-2 no-underline {% if page.active_nav == "runs" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/runs"{% if page.active_nav == "runs" %} aria-current="page"{% endif %}>Runs</a>
|
||||||
|
<a class="py-2 no-underline {% if page.active_nav == "articles" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/articles"{% if page.active_nav == "articles" %} aria-current="page"{% endif %}>Articles</a>
|
||||||
|
<a class="py-2 no-underline {% if page.active_nav == "ratings" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/ratings"{% if page.active_nav == "ratings" %} aria-current="page"{% endif %}>Ratings</a>
|
||||||
|
<a class="py-2 no-underline {% if page.active_nav == "profile" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/profile"{% if page.active_nav == "profile" %} aria-current="page"{% endif %}>Profile</a>
|
||||||
|
<a class="py-2 no-underline {% if page.active_nav == "stats" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/stats"{% if page.active_nav == "stats" %} aria-current="page"{% endif %}>Stats</a>
|
||||||
|
<a class="py-2 no-underline {% if page.active_nav == "jobs" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/jobs"{% if page.active_nav == "jobs" %} aria-current="page"{% endif %}>Jobs</a>
|
||||||
|
<a class="py-2 no-underline {% if page.active_nav == "settings" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/settings"{% if page.active_nav == "settings" %} aria-current="page"{% endif %}>Settings</a>
|
||||||
|
<a class="py-2 no-underline {% if page.active_nav == "users" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/users"{% if page.active_nav == "users" %} aria-current="page"{% endif %}>Users</a>
|
||||||
|
</nav>{% endif %}
|
||||||
|
</header>
|
||||||
|
{% match page.flash %}{% when Some with (flash) %}<div class="mx-auto max-w-7xl px-4 sm:px-6"><div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div></div>{% when None %}{% endmatch %}
|
||||||
|
<main id="content" class="min-h-[68vh]">{% block content %}{% endblock %}</main>
|
||||||
|
<footer class="mx-auto mt-16 flex max-w-7xl flex-wrap justify-between gap-2 border-t border-rule px-4 py-6 font-sans text-[0.72rem] uppercase tracking-[0.1em] text-muted sm:px-6"><span>The Daily EPUB</span><span>daily-epub {{ page.version }}</span></footer>
|
||||||
<script src="/static/app.js?v={{ page.version }}"></script>
|
<script src="/static/app.js?v={{ page.version }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{% extends "layout.html" %}{% block content %}<section class="reading narrow"><h1>Sign in</h1>{% if !error.is_empty() %}<p class="error">{{ error }}</p>{% endif %}<form method="post" action="/login"><input type="hidden" name="next" value="{{ next }}"><label>Username <input name="username" autocomplete="username" required></label><label>Password <input type="password" name="password" autocomplete="current-password" required></label><button type="submit">Sign in</button></form></section>{% endblock %}
|
{% extends "layout.html" %}{% block ears %}<span>Reader access</span>{% endblock %}{% block content %}<section class="mx-auto mt-12 max-w-md px-4 sm:mt-16 sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Reader access</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">Sign in</h1><p class="mt-4 text-ink-2">Open the full paper, article chapters, and downloads.</p>{% if !error.is_empty() %}<p class="error mt-6">{{ error }}</p>{% endif %}<form class="mt-8 grid gap-5 font-sans text-sm" method="post" action="/login"><input type="hidden" name="next" value="{{ next }}"><label class="grid gap-1.5 font-medium">Username <input class="min-h-11 w-full" name="username" autocomplete="username" required></label><label class="grid gap-1.5 font-medium">Password <input class="min-h-11 w-full" type="password" name="password" autocomplete="current-password" required></label><button class="btn-primary mt-1 w-full" type="submit">Sign in</button></form></section>{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1 @@
|
|||||||
{% extends "layout.html" %}{% block content %}<article class="reading world-briefing">
|
{% extends "layout.html" %}{% block ears %}<span>{{ display_date }}</span>{% endblock %}{% block content %}<article class="mx-auto mt-10 max-w-[68ch] px-4 sm:mt-14 sm:px-6"><header class="mb-9"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ display_date }}</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">World Briefing</h1></header><div class="prose-body border-t border-rule pt-7">{{ body_html|safe }}</div><p class="mt-10 border-t border-rule pt-5 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></article>{% endblock %}
|
||||||
<h1>World Briefing</h1><p class="dateline">{{ display_date }}</p><hr class="rule">
|
|
||||||
<div class="world-body">{{ body_html|safe }}</div>
|
|
||||||
<p><a href="{{ issue_href }}">← Back to this issue</a></p>
|
|
||||||
</article>{% endblock %}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user