From 55820b345898c5d25cf34f76d72396d88c4ef87e Mon Sep 17 00:00:00 2001 From: JustinZeus Date: Wed, 22 Jul 2026 22:28:15 +0200 Subject: [PATCH] Bootstrap the Scholarr rewrite --- .gitignore | 36 + AGENTS.md | 95 + CLAUDE.md | 1 + README.md | 36 + TASKS.md | 89 + design/DESIGN.md | 375 ++++ design/README.md | 15 + design/assets/favicon-512.png | Bin 0 -> 40296 bytes design/assets/favicon.png | Bin 0 -> 40296 bytes design/assets/icons/activity.svg | 1 + design/assets/icons/arrow-left.svg | 1 + design/assets/icons/authors.svg | 1 + design/assets/icons/book.svg | 1 + design/assets/icons/check.svg | 1 + design/assets/icons/chev-left.svg | 1 + design/assets/icons/chev-right.svg | 1 + design/assets/icons/circle.svg | 1 + design/assets/icons/dashboard.svg | 1 + design/assets/icons/dot.svg | 1 + design/assets/icons/external.svg | 1 + design/assets/icons/eye.svg | 1 + design/assets/icons/lock.svg | 1 + design/assets/icons/moon.svg | 1 + design/assets/icons/oa.svg | 1 + design/assets/icons/pending.svg | 1 + design/assets/icons/plus.svg | 1 + design/assets/icons/publications.svg | 1 + design/assets/icons/review.svg | 1 + design/assets/icons/search.svg | 1 + design/assets/icons/settings.svg | 1 + design/assets/icons/signout.svg | 1 + design/assets/icons/sources.svg | 1 + design/assets/icons/sun.svg | 1 + design/assets/icons/system.svg | 1 + design/assets/icons/undo.svg | 1 + design/assets/icons/upload.svg | 1 + design/assets/icons/warning.svg | 1 + design/assets/icons/x.svg | 1 + design/assets/logo-dark.png | Bin 0 -> 136465 bytes design/assets/logo-light.png | Bin 0 -> 128008 bytes design/component-reference.html | 212 ++ design/reference/Scholarr.dc.html | 1078 ++++++++++ design/reference/support.js | 1841 +++++++++++++++++ .../uploads/android-chrome-512x512.png | Bin 0 -> 40296 bytes design/reference/uploads/logo-dark-tight.png | Bin 0 -> 136465 bytes design/reference/uploads/logo-dark.png | Bin 0 -> 88769 bytes design/reference/uploads/logo-light-tight.png | Bin 0 -> 128008 bytes design/reference/uploads/scholar_logo.png | Bin 0 -> 111212 bytes design/tokens.css | 106 + docs/specs/README.md | 11 + 50 files changed, 3924 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 TASKS.md create mode 100644 design/DESIGN.md create mode 100644 design/README.md create mode 100644 design/assets/favicon-512.png create mode 100644 design/assets/favicon.png create mode 100644 design/assets/icons/activity.svg create mode 100644 design/assets/icons/arrow-left.svg create mode 100644 design/assets/icons/authors.svg create mode 100644 design/assets/icons/book.svg create mode 100644 design/assets/icons/check.svg create mode 100644 design/assets/icons/chev-left.svg create mode 100644 design/assets/icons/chev-right.svg create mode 100644 design/assets/icons/circle.svg create mode 100644 design/assets/icons/dashboard.svg create mode 100644 design/assets/icons/dot.svg create mode 100644 design/assets/icons/external.svg create mode 100644 design/assets/icons/eye.svg create mode 100644 design/assets/icons/lock.svg create mode 100644 design/assets/icons/moon.svg create mode 100644 design/assets/icons/oa.svg create mode 100644 design/assets/icons/pending.svg create mode 100644 design/assets/icons/plus.svg create mode 100644 design/assets/icons/publications.svg create mode 100644 design/assets/icons/review.svg create mode 100644 design/assets/icons/search.svg create mode 100644 design/assets/icons/settings.svg create mode 100644 design/assets/icons/signout.svg create mode 100644 design/assets/icons/sources.svg create mode 100644 design/assets/icons/sun.svg create mode 100644 design/assets/icons/system.svg create mode 100644 design/assets/icons/undo.svg create mode 100644 design/assets/icons/upload.svg create mode 100644 design/assets/icons/warning.svg create mode 100644 design/assets/icons/x.svg create mode 100644 design/assets/logo-dark.png create mode 100644 design/assets/logo-light.png create mode 100644 design/component-reference.html create mode 100644 design/reference/Scholarr.dc.html create mode 100644 design/reference/support.js create mode 100644 design/reference/uploads/android-chrome-512x512.png create mode 100644 design/reference/uploads/logo-dark-tight.png create mode 100644 design/reference/uploads/logo-dark.png create mode 100644 design/reference/uploads/logo-light-tight.png create mode 100644 design/reference/uploads/scholar_logo.png create mode 100644 design/tokens.css create mode 100644 docs/specs/README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43808d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Editors and operating systems +.DS_Store +.idea/ +.vscode/ + +# Local configuration and secrets +.env +.env.* +!.env.example +*.local.yaml +*.local.yml + +# Go +/bin/ +/dist/ +coverage.out +*.test + +# Vue and Node +node_modules/ +frontend/dist/ +*.tsbuildinfo + +# Runtime data +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 +/data/ +/backups/ + +# Temporary files +*.log +*.tmp +.cache/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e1eadcc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# Scholarr agent brief + +This file is the canonical brief for every coding agent working in this repository. Read it and +`TASKS.md` before making changes. + +## Product + +Scholarr is a public, self-hosted FOSS author watchlist and publication tracker. A user follows +authors, learns when they publish, and finds legal open-access PDFs. It must serve a real +multi-user household deployment while remaining straightforward for the wider self-hosting +community. + +## Immutable doctrine + +There is zero Google Scholar network contact, ever. + +- No application code, test, tool, CI job, or documentation example may request a Google + endpoint. +- No scraping, browser automation, CAPTCHA handling, anti-bot behavior, or evasion machinery. +- Scholar IDs and URLs may exist only as inert imported metadata and human-clickable links. +- Acquisition uses documented APIs: OpenAlex, Crossref, arXiv, Unpaywall, and ORCID. +- Semantic Scholar is not a v1 requirement. +- Provider clients must use a contact identity, conservative persisted rate limits, restart-safe + cooldowns, and user-visible health. OpenAlex must stay at or below 2 requests per second. + +This doctrine outranks convenience and implementation shortcuts. + +## Locked architecture + +- Backend: Go. +- Frontend: Vue. +- Database: SQLite only. Design for single-writer discipline from the start. +- Root author entity: `FollowedAuthor`, with one or more `AuthorSourceIdentity` records. +- Names are labels, never identity keys. External source IDs anchor identity. +- Publications are globally deduplicated. Follows and read state are per user. +- Auth v1 includes internal username/password, generic OIDC, and trusted-header/forward-auth. +- The service is config-driven. One declarative config controls providers and behavior. +- GitHub `justinzeus/scholarr` is the intended public primary. Forgejo keeps a copy. + +Do not silently change a locked decision. Record a proposed change as open and ask the owner. + +## Decision record + +On tank, the authoritative project runbook is: + +`/opt/stacks/server-docs/runbooks/scholarr-rewrite/README.md` + +It owns doctrine, locked decisions, empirical calibration results, and project state. Its +companion files own the identity model and testing strategy. This brief is self-contained for +agents without tank access, but any state or design decision reached on tank must also be written +to that runbook in the same session. + +## Frozen UI contract + +- `design/DESIGN.md` is the frozen Vue handoff contract. +- `design/tokens.css`, `design/component-reference.html`, and `design/assets/` are canonical UI + inputs. +- `design/reference/Scholarr.dc.html` is the reviewed proof implementation. +- `design/reference/support.js` belongs only to that design proof. Never ship it in production. +- Port the design faithfully. Do not redesign while implementing. +- Carry two cleanup items into the Vue port: avoid em dashes in bulk-import copy, and do not show + raw match percentages as user-facing confidence labels. + +UI QA is manual and performed by the owner against the frozen contract. Do not add or run +Playwright, Selenium, or other browser automation. Unit, integration, build, and curl smoke tests +are expected. + +## Work process + +- Work through `TASKS.md` in order. Each task has a goal and acceptance criteria. +- Freeze one spec at a time. Decided items are labeled decided; open items remain open for owner + review. +- Do not start implementation for an area whose spec is not frozen. +- Keep commits focused and use plain imperative commit messages without trailers. +- Stage only files you changed. +- Do not use em dashes in prose, code comments, commit messages, or user-facing copy. +- Never commit secrets, real user data, database dumps, provider payloads containing private + data, API keys, tokens, or real email addresses. +- Keep configuration examples synthetic and safe to publish. + +## Correctness obligations + +- Pure normalization, fingerprinting, parsing, and scoring logic gets table tests, property tests + for invariants, and Go fuzz tests for external input parsers. +- Provider clients use deterministic `httptest` fixtures, including 429 responses, missing + `Retry-After`, truncated JSON, and empty pages. Rate and cooldown tests use a fake clock. +- Sync tests prove one active run per user, idempotent reruns, additive-only baseline import, + resumability after interruption, and SQLite concurrency behavior. Run the race detector. +- Every migration is tested up and down, including upgrade from a seeded prior-version database. +- End-to-end smoke boots a seeded SQLite database with stub providers and exercises onboarding, + import, sync, review, read state, and export through the API. +- Matching and dedup changes may not regress the recorded golden-corpus baseline. + +Dry-run import previews and dry-run destructive admin actions are product features, not test-only +helpers. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5d30268 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +Read AGENTS.md. diff --git a/README.md b/README.md new file mode 100644 index 0000000..56b3709 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Scholarr + +Scholarr is a self-hosted author watchlist and publication tracker. Follow researchers, learn +when they publish, and find legal open-access copies of their work. + +The rewrite is pre-alpha. Its product direction and interface are settled, and the architecture +specs are being frozen before implementation begins. + +## Non-negotiable rule + +Scholarr never makes network requests to Google Scholar or any other Google endpoint. Imported +Scholar IDs and URLs are inert metadata and human-clickable links only. Publication data comes +from documented APIs such as OpenAlex, Crossref, arXiv, Unpaywall, and ORCID. + +## Technical direction + +- Go backend +- Vue frontend +- SQLite only +- Multi-user, with a globally deduplicated publication store and per-user follow/read state +- Internal auth, generic OIDC, and trusted-header auth in v1 +- Config-driven operation and simple first-run onboarding + +See [AGENTS.md](AGENTS.md) for the full project constraints and [TASKS.md](TASKS.md) for the +ordered work queue. + +## Frozen interface contract + +The reviewed interface contract is in [design/DESIGN.md](design/DESIGN.md). Its tokens, component +reference, icons, logos, favicons, and proof implementation are checked in beside it. These files +are the source for the Vue port, not an application implementation. + +## License + +The license has not been selected yet. Until a LICENSE file is added, normal copyright rules +apply. License selection is an explicit project decision in [TASKS.md](TASKS.md). diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..8909e15 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,89 @@ +# Scholarr task cards + +Work in order. A card is complete only when its acceptance criteria are met. Specs are reviewed +and frozen one at a time before implementation begins. + +## Card 0: publish the clean repository **IN PROGRESS** + +**Goal:** publish this clean-history bootstrap as the public project. + +The local bootstrap is complete and the personal GitHub identity is authenticated on tank. Publish +this same history to GitHub first, then to the Forgejo copy. + +**Acceptance:** public `justinzeus/scholarr` repositories exist on GitHub (primary) and Forgejo +(copy), both point at this clean history, no legacy history is present, and no secret or work +identity appears anywhere. + +## Card 1: freeze the data model spec + +**Goal:** specify global author/publication identity, per-user follows/read state, review records, +and merge/undo behavior. + +Inputs: the project identity decision record, the legacy schema concepts worth salvaging, and the +global `FollowedAuthor` plus `AuthorSourceIdentity` direction. + +**Acceptance:** `docs/specs/data-model.md` is owner-reviewed and marked frozen; constraints, +migrations, duplicate prevention, audit/undo behavior, and every remaining open question are +explicit. + +## Card 2: freeze provider contracts and health + +**Goal:** specify sanctioned API contracts, etiquette, persisted rate clocks/cooldowns, and +user-visible source health for OpenAlex, Crossref, arXiv, Unpaywall, and ORCID. + +**Acceptance:** `docs/specs/providers.md` is owner-reviewed and marked frozen; keyed and anonymous +lanes, contact identity, retry semantics, restart behavior, fixture boundaries, and open questions +are explicit. + +## Card 3: freeze the sync engine and run lifecycle + +**Goal:** define safe, resumable acquisition within SQLite's concurrency model. + +**Acceptance:** `docs/specs/sync-engine.md` is owner-reviewed and marked frozen; it defines one +active run per user, idempotent reruns, additive-only baseline import, resumability, writer +serialization, dry runs, and failure recovery. + +## Card 4: freeze PDF resolution + +**Goal:** define legal open-access discovery and provenance. + +**Acceptance:** `docs/specs/pdf-resolution.md` is owner-reviewed and marked frozen; source order, +license/provenance handling, retry behavior, and the absence of all Google access are explicit. + +## Card 5: freeze auth + +**Goal:** define internal, generic OIDC, and trusted-header auth without provider-specific coupling. + +**Acceptance:** `docs/specs/auth.md` is owner-reviewed and marked frozen; identity linking, roles, +session/security behavior, first-admin recovery, and mode-specific trust boundaries are explicit. + +## Card 6: freeze the config schema + +**Goal:** define one declarative configuration surface for the service. + +**Acceptance:** `docs/specs/config.md` is owner-reviewed and marked frozen; defaults, validation, +secret references, provider settings, reload/restart behavior, and upgrade compatibility are +explicit. + +## Card 7: freeze onboarding UX + +**Goal:** make first run straightforward: search or paste Scholar URLs, review candidates, confirm, +and begin tracking. + +**Acceptance:** `docs/specs/onboarding.md` is owner-reviewed and marked frozen; empty, ambiguous, +cooldown, partial-success, and unresolved-shell paths match the frozen UI and honest calibration +expectations. + +## Card 8: choose the public license + +**Goal:** make the repository genuinely open source under a consciously selected OSI license. + +**Acceptance:** the owner selects the license, `LICENSE` is added with correct copyright identity, +and README/project metadata are updated. + +## Card 9: plan build phases + +**Goal:** split implementation into reviewable vertical slices against the frozen specs and UI. + +**Acceptance:** build cards cover schema/migrations, providers, sync, auth, API, Vue port, +packaging, backup/restore, and release gates. Every card names its tests and demo proof. diff --git a/design/DESIGN.md b/design/DESIGN.md new file mode 100644 index 0000000..de0371a --- /dev/null +++ b/design/DESIGN.md @@ -0,0 +1,375 @@ +# Scholarr — Design Contract (DESIGN.md) + +This is the binding design specification for the Scholarr web app. It is written +for the engineering team building the Vue 3 SPA. Everything visual in the app is +expressed through the tokens in `tokens.css`; every screen is a composition of the +named components below. If an implementation choice is not covered here, prefer the +option that is calmest, most honest about data, and easiest for a non-technical +seventy-year-old to understand. + +The working reference implementation is `Scholarr.dc.html` (all pages, all states, +light/dark, responsive). This document is the contract; the file is the proof. + +--- + +## 1. Product principles that shape the UI + +1. **Honesty about sources is a feature.** Never fake real-time. Show "last delivered" + facts and set the expectation that data lags reality by days to weeks. Every source + has a visible health state. +2. **Identity is probabilistic.** Matching a followed name to a database identity has a + confidence level. Ambiguous cases go to a human review queue — a first-class daily + surface, not an admin afterthought. +3. **Nothing destructive by default.** Bulk actions preview and count before they act; + every consequential decision is undoable. +4. **Two audiences, one app.** The founding user is a retired academic who wants to know + "anything new for me?" in one glance. The admin wants dense, clutter-free control. + The Dashboard belongs to the reader; Sources/Activity/Settings belong to the admin. +5. **Self-hostable and private.** System-font stacks only; no CDN, no externally loaded + fonts, no third-party assets. + +--- + +## 2. Signature & aesthetic + +- **Concept: "the living bibliography."** The reading surface is typeset like a reference + list — serif titles, monospace identifiers (DOIs, arXiv IDs), a measured reading column. +- **Signature element:** the mortarboard-with-descending-binary logo (bundled as + `logo-light-tight.png` / `logo-dark-tight.png`), echoed by the honest **"as of" / "last + delivered" freshness stamps** that appear anywhere data is shown. +- **Type roles:** serif for titles & publication entries; sans for all chrome/UI; mono for + identifiers, timestamps, counts and uppercase micro-labels. Never set body UI in serif. +- **Restraint:** at most two background tones per screen. The binary motif appears only in + the logo — never as decoration. + +--- + +## 3. Color tokens + +Authored in oklch (see `tokens.css`); sRGB hex below for tools that need it. Semantic +colors are shared by two vocabularies: **matching confidence** and **source health**. + +| Token | Role | Light | Dark | +|---|---|---|---| +| `--paper` | app background | `#fcfaf6` | `#161719` | +| `--surface` | cards, panels | `#fffefc` | `#1f2123` | +| `--surface-2` | insets, hover, skeleton | `#f6f3ee` | `#2b2d30` | +| `--ink` | primary text | `#29231d` | `#e9ebee` | +| `--ink-soft` | secondary text | `#5d5751` | `#afb1b4` | +| `--ink-faint` | meta / disabled | `#8b8580` | `#7e8084` | +| `--line` | hairline borders | `#e1ddd9` | `#35373a` | +| `--line-strong` | control borders | `#cbc6c1` | `#505357` | +| `--accent` | primary action fill | `#2e6799` | `#6aaae6` | +| `--accent-soft` | tinted backgrounds | `#dceeff` | `#1f3654` | +| `--accent-ink` | accent text | `#055085` | `#98cdff` | +| `--hi` / `--hi-soft` | **healthy / confident** | `#519160` / `#d9f3dd` | `#6cc185` / `#193d2f` | +| `--mid` / `--mid-soft` | **cooling down / likely** | `#bd8630` / `#fce9c6` | `#deaf56` / `#46381a` | +| `--low` / `--low-soft` | **failing / uncertain** | `#c0453f` / `#ffe0d8` | `#f47c6e` / `#562e27` | + +Rules: text on `--paper`/`--surface` uses `--ink`/`--ink-soft`. Solid `--accent`/`--hi`/ +`--mid`/`--low` fills always pair with near-white text (`oklch(0.99 0 0)`) — verify AA. +`-soft` tokens are backgrounds only; pair with the matching solid token for text. + +--- + +## 4. Typography + +| Role | Font | Size | Weight | Notes | +|---|---|---|---|---| +| Page title (H1) | serif | `clamp(21px, 4vw, 27px)` | 700 | letter-spacing −0.015em | +| Section / card title | serif | 15–17px | 700 | | +| Publication title | serif | 14.5–16.5px | 700 unread / 500 read | `text-wrap: pretty` | +| Body | sans | 13–14px | 400–500 | line-height 1.45 | +| Meta / secondary | sans | 11.5–12.5px | 400 | `--ink-soft` | +| Identifiers, timestamps, counts | mono | 10.5–11.5px | 400–700 | `--ink-faint` | +| Uppercase micro-label | mono | 10px | 700 | letter-spacing 0.08–0.12em | + +Minimum body text 12px. Titles truncate to one line with ellipsis in dense lists; wrap in +detail/feed views. + +--- + +## 5. Space, radius, elevation + +- **Space scale:** 4 / 8 / 12 / 16 / 20 / 24 / 32 / 40. Lay groups out with flex/grid + `gap`. +- **Radius:** `--r-sm` 6 (controls), `--r-md` 10 (option cards), `--r-lg` 16 (panels), `--r-pill` 20 (chips/badges). +- **Elevation:** `--sh-1` resting cards; `--sh-2` overlays (mobile bars, modals, toasts). Borders (`--line`) do most of the separation work; shadows are subtle. +- **Reading column:** dashboard ≤ 780px, publications/authors ≤ 1100px, detail/review/forms ≤ 820–900px, centered. + +--- + +## 6. The two badge vocabularies + +Both render as a pill: `--r-pill`, mono 10–11px 700, `-soft` background + solid-token text, +optional leading dot. **These words are the end-user language — do not show raw scores or +internal states in the UI.** + +### Matching confidence (author identity) +| Internal | Badge label | Token | When | +|---|---|---|---| +| high (≥ ~90%) | **Confident** | `--hi` | Identifiers + works agree across sources | +| medium | **Likely** | `--mid` | Probable, but the name is shared — worth a look | +| low | **Uncertain** | `--low` | Weak evidence; review before trusting | +| shell | **Not yet identified** | `--ink-faint` | A name with no database identity | + +### Source health +| Internal | Badge label | Token | Plain-language meaning | +|---|---|---|---| +| healthy | **Healthy** | `--hi` | Delivering normally | +| cooling | **Cooling down** | `--mid` | We asked too fast; the source told us to slow down. Resumes on its own. | +| failing | **Not connected** | `--low` | Sign-in/credentials failed; matching continues from other sources | + +### Open-access availability (per publication) +`◈ OA` (`--hi`) available · `◇ ?` (`--ink-faint`) checking Unpaywall · `⊘` (`--low`) no free PDF found. + +--- + +## 7. Component inventory (with states) + +Every component below is component-shaped: repeated instances are structurally identical, +styled only through tokens, translatable 1:1 to a scoped Vue component. + +- **Buttons** — *primary* (`--accent` fill, near-white text), *secondary* (`--surface` + + `--line-strong` border), *ghost* (transparent, `--ink-soft`), *destructive-lite* + (`--low` text on `--surface`). States: default / hover (`--surface-2` or darker accent) / + focus-visible (2px `--accent` ring) / disabled (`--ink-faint`, dashed border for + unavailable actions like a missing PDF) / active-pressed. +- **Inputs & selects** — `--surface-2` fill, `--line-strong` border, label in `--ink-soft` + 12px. Focus ring as global. (Static in mockups; real fields in Vue.) +- **Filter chips / segmented control** — pill, active = `--accent-soft` bg + `--accent-ink` + text + `--accent` border; inactive = `--surface` + `--line`. Optional mono count sub-pill. +- **List row (publication)** — `[unread dot] [field tag · serif title (truncate)] [meta line: + authors · venue · year · id · source] [NEW marker slot] [OA badge] [PDF button] [read toggle]`. + Unread: filled `--unread` dot with `--accent-soft` halo, title 700. Read: hollow dot, + title 500, row opacity 0.66. Hover: `--surface-2`. +- **List row (author)** — `[checkbox] [avatar] [name (+ transliteration)] [field] [confidence + badge] [last sync] [new count]`. Selectable; header row labels the columns. +- **Table header** — mono uppercase micro-labels, `--line` bottom border. +- **Cards / panels** — `--surface`, `--line` border, `--r-lg`, `--sh-1`; header row with serif + title + optional mono meta, hairline-separated body. +- **Avatar** — circle, initials, `--surface-2` (neutral) or `--accent-soft` (self/active). +- **Badges** — confidence / source-health / OA / role, per §6. +- **Freshness stamp** — mono `--ink-faint`, always phrased "as of …" or "last delivered …". +- **Progress bar** — 6px track `--surface-2`, `--accent` fill (review queue, bulk import). +- **Modal / overlay** — `--surface`, `--sh-2`, `--r-lg`; dim scrim. (Pattern; compose as needed.) +- **Toast** — `-soft` bg + matching solid icon + `--sh-2`; auto-dismiss, carries an Undo action + for consequential changes. +- **Empty state** — centered glyph + serif headline + one sentence of `--ink-soft` guidance + + one primary action. Never a dead end. +- **Skeleton** — `--surface-2`→`--line` shimmer, respects reduced-motion (freezes). Mirrors the + real row's shape and count. +- **Bottom tab bar (mobile)** — fixed, `--surface`, `--sh-2`, 5 destinations, badge dots. + +### Required states per page +Populated, **empty**, **loading (skeleton)**, and **degraded** are demonstrated in the +reference file. Degraded truths that must remain visible: a cooling-down source (Unpaywall), +a not-connected source (ORCID), an unresolvable PDF, an author shell, and a +three-plausible-candidates review case. + +--- + +## 8. Interaction patterns + +- **Navigation** — persistent collapsible left sidebar on desktop; a fixed bottom tab bar on + phone. The nav must never scroll horizontally (v1's bug). Below 760px the sidebar is replaced, + not shrunk. +- **Theme** — system / light / dark switch. System follows `prefers-color-scheme`; explicit + choice sets `data-theme` on the root. +- **Bulk selection** — row checkboxes reveal a selection bar with count + non-destructive actions + (re-sync, send to review) + Clear. Nothing acts without an explicit button. +- **Keyboard triage (Review queue)** — one decision at a time. `1`/`2`/`3` choose a candidate, + `S` skip, `U` undo, `J`/`K` (or ↑/↓) move. Visible `` hints. Every decision is reversible. +- **Undo** — bulk imports and review decisions are undoable; imports can be reversed from Activity. +- **Freshness** — show relative times ("2h ago") plus absolute where precision matters. Never imply + live data. + +--- + +## 9. Copy rules & phrase glossary + +- Address the reader plainly and warmly; avoid jargon and internal terms. +- **No em dashes.** Use periods, commas, or a middot (·) separator. +- Numbers and identifiers in mono; prose in sans. +- Never say "scraped", "crawler", "confidence score 0.71", "rate-limited (HTTP 429)". Say the + plain-language equivalent. + +| Say this | Not this | +|---|---| +| Confident match / Likely / Uncertain | 0.98 / 0.71 / 0.48, high/med/low | +| Not yet identified (author shell) | null identity, orphan record | +| Cooling down — resumes on its own | rate-limited, HTTP 429, backoff | +| Not connected — sign-in was rejected | auth failure 401 | +| No free PDF found | Unpaywall miss | +| open-access PDF | OA green/gold, self-archived | +| last delivered 2h ago / as of … | live, real-time, up to the second | +| We will keep checking on schedule | polling, cron | + +--- + +## 10. Accessibility + +- **Contrast:** WCAG AA for all text and meaningful UI. Verify solid-fill + near-white text and + every `-soft`/solid pairing in both themes. +- **Focus:** visible 2px `--accent` ring on every interactive element (`:focus-visible`). +- **Reduced motion:** `prefers-reduced-motion` freezes skeleton shimmer and all transitions. +- **Color is never the only signal:** confidence and health always pair the color with a word + (and often a dot/icon); unread/read pair color with dot fill and weight. +- **Targets:** ≥ 44px hit targets on mobile controls. +- **Semantics:** real headings, buttons for actions, labels tied to inputs; transliterated names + carry the Latinized form alongside. + +--- + +## 11. Vue 3 mapping notes + +- Ship `tokens.css` once at the app root; never hard-code a color, font, radius, or shadow — + reference a `var(--token)`. Theme switching only toggles `data-theme` on the root. +- Each component in §7 becomes one scoped SFC. Keep markup identical across repeated instances + (the reference file already does this) so the port is mechanical. +- Derived styles that depend on data (confidence color, read/unread weight, active chip) should + be computed props returning token names, not literal colors. +- No technique in the reference relies on anything Vue-hostile: no global class cascade beyond + tokens + a few resets, no runtime style injection, no third-party fonts or assets. + + +--- + +## 3b. Contrast proof (WCAG AA) + +Measured contrast ratios for every token pairing used for **text** (AA needs 4.5:1 for body, 3:1 for large). Ratios ≥ 4.5 unless noted. + +New text-safe tokens added this round: `--hi-ink` / `--mid-ink` / `--low-ink` (AA text on the matching `-soft` background), and `--on-accent` (text on solid fills — flips near-white in light, near-black in dark). + +| Pairing | Light | Dark | +|---|---|---| +| `--ink` on `--surface` | 15.4 | 13.5 | +| `--ink-soft` on `--surface` | 7.1 | 7.5 | +| `--ink-faint` on `--surface` | 4.8 | 5.2 | +| `--accent-ink` on `--surface` | 8.4 | 9.6 | +| `--accent-ink` on `--accent-soft` | 7.1 | 7.3 | +| `--hi-ink` on `--hi-soft` | 6.2 | 7.9 | +| `--mid-ink` on `--mid-soft` | 6.4 | 7.4 | +| `--low-ink` on `--low-soft` | 6.0 | 6.3 | +| `--on-accent` on `--accent` (primary btn) | 5.8 | 7.7 | +| `--on-accent` on `--low` (destructive btn) | 4.9 | 7.2 | + +Rule that made the earlier "Likely / Cooling down" badge (2.66:1) pass: **solid semantic colours (`--hi`/`--mid`/`--low`) are never used as badge text.** Badge text is always the `-ink` variant on the `-soft` background. Solid fills only ever carry `--on-accent` text, and only for `--accent`/`--low` (never `--hi`/`--mid`, whose light-theme solids fail with white). `--ink-faint` was darkened to clear AA on `--surface`. + +## 12. Motion & interaction spec + +Tokens (in `tokens.css`): `--dur-fast` 120ms, `--dur-base` 180ms, `--dur-slow` 260ms; `--ease-out` cubic-bezier(.2,0,0,1), `--ease-in-out` cubic-bezier(.4,0,.2,1). + +- **Animates:** hover/focus colour transitions (fast); toggle-switch knob (base); filter/theme chip selection (fast); skeleton shimmer (1.4s loop); toast slide-up + fade (base); modal scrim fade + card rise (base); tooltip fade+rise (fast). +- **Never animates:** content arriving from a sync (rows appear without shifting neighbours — reserve space with skeletons); route/screen changes (instant, no cross-fade — this is a data tool); numbers/counts (no count-up); the reading list on mark-read (opacity change only, no reflow/animation of position). +- **Reduced motion** (`prefers-reduced-motion: reduce`, handled globally in tokens.css): all transitions/animations collapse to ~0ms; skeleton shimmer freezes to a static tint; tooltips and toasts appear/disappear instantly; no scroll animation. + +**Keyboard triage (Review queue)** — one decision on screen at a time: + +| Key | Action | +|---|---| +| `1` `2` `3` | Choose candidate 1/2/3 | +| `S` | Skip (decide later) | +| `U` | Undo last decision | +| `J` / `↓` | Next item | +| `K` / `↑` | Previous item | + +Every decision is reversible; the queue shows progress (`n of N`) and an always-available Undo. Nothing is committed to the shared identity graph until the user decides. + +**Bulk selection** (Authors): row checkboxes reveal a selection bar with a live count and only non-destructive actions (Re-sync, Send to review) plus Clear. No action fires without an explicit button press; destructive operations route through the confirm modal (§17). + +## 13. Responsive spec + +Breakpoint: **phone < 760px**, **wide ≥ 760px** (single breakpoint by design — the tool is comfortable on a 13" laptop, usable on a phone). Documented as `--bp-phone: 760px`; because custom properties can't drive `@media`, the Vue app defines the query once in a composable and the value must match this token. + +Per-surface reflow: +- **Navigation:** wide = persistent left sidebar (collapsible to 64px icon rail); phone = fixed **bottom tab bar** + a slim top bar. The nav never scrolls horizontally (the v1 bug). Below 760 the sidebar is *replaced*, not shrunk. +- **Dashboard:** hero + feed stack to one column; the "new work from" author chips wrap. +- **Publications:** rows keep their shape; the meta line truncates with ellipsis; filter chips scroll inside their own contained track (never the page). +- **Authors table:** columns drop progressively on phone — keep Author + Identity(confidence) + New; hide Field and Last-sync (available in author detail). The row stays the same component, columns toggle via the breakpoint. +- **Author detail / Add / Review / forms:** two-column layouts collapse to one; the identity panel moves below the publications list. +- **Modal:** full-width minus 20px margin on phone; **toast** spans near-full width, bottom, above the tab bar. +- Reading-column caps: dashboard 780, publications/authors 1100, detail/sources/activity 900, review/add/forms 820. + +## 14. Form rules + +- **Labels above inputs**, 12px `--ink-soft` 600. Required fields marked with `*` in `--low-ink` and stated once ("Fields marked * are required"). +- **Validation on save**, not per keystroke (calm for the founding user). On failure: input gets `--low` border, an inline message below the field (`--low-ink`, 11.5px, leading `warning` icon), AA-legible; focus moves to the first invalid field. +- **Submit states:** default "Save changes" → disabled "Saving…" → success shows an inline "saved" banner (`--hi-soft`/`--hi-ink` + check) AND a toast. Cancel reverts to the read-only view with no change. +- Read-only view shows values as plain text (not disabled inputs); an explicit **Edit** button enters the form. This mirrors the Settings → Profile states (view / editing / error / saving / saved). + +## 15. Copy glossary (complete — one term per concept) + +Confidence: **Confident** · **Likely** · **Uncertain** · **Not yet identified** (shell). +Source health: **Healthy** · **Cooling down** · **Not connected**. +Open access: **open-access PDF** (available) · **checking** (Unpaywall resolving) · **no free PDF** (none found). +Freshness (always relative + honest, never "live"): **"last delivered {t}"**, **"as of {t}"**, **"last checked {t}"**, **"Up to date · {t}"**, **"arXiv can run a day or two behind. That is normal."** +Review actions: **Skip for now** · **Undo** · **None of these** / **Reject match** / **Keep separate** / **Keep both** / **Leave as shell** (reject label varies by case) · **Merge** (duplicates). +Bulk import: **"We checked your {n} lines before importing anything"**, **"look valid"**, **"could not be read"**, **"duplicates within your list"**, **"already in your followed list"**, **"Follow {n} confident matches"**, **"Send {n} to review"**, **"Safe by default: nothing is followed until you confirm."** +Empty states (one line each): Publications "Nothing to read yet." · Authors "You are not following anyone yet." · Author detail "No publications from this author are in your library yet." · Review "Queue is clear." · Sources "No sources connected yet." · Dashboard feed "You are all caught up." +Error patterns: **404** "That page is not on the shelf. … Nothing is broken." · Login fail "That username or password was not recognised. Please try again." · Field "Enter a valid email address, for example name@example.org." · Source fail "Not delivering. Sign-in or credentials failed; the other sources keep working." +Destructive confirm: **"Remove {name}?"** + what is and isn't affected + **"Remove user"** / **Cancel**; success toast **"{name} was removed. Undo?"** +Never shown to users: raw scores/percentages as the label, "scraped", "crawler", "rate-limited/429", "auth 401", "polling/cron", "real-time". + +## 16. Icon set + +All icons: 24×24 viewBox, `fill:none; stroke:currentColor; stroke-width:1.8; round caps/joins`. Shipped as `assets/icons/{name}.svg` and as an inline `` sprite. Inherit colour from context; size via width/height. + +`dashboard` (nav: home/overview) · `publications` (nav: reading list) · `authors` (nav: followed people) · `review` (nav: identity triage / balance) · `sources` (nav: data providers / database) · `activity` (nav: sync history) · `settings` (nav: gear) · `search` · `check` (read / confirmed / valid) · `circle` (unread-toggle empty / read-only) · `dot` (filled status dot) · `plus` (add) · `external` (outbound link / open PDF in new tab) · `chev-left` / `chev-right` (collapse / paginate) · `arrow-left` (back) · `undo` · `sun` / `moon` / `system` (theme) · `signout` · `warning` (caution / validation) · `x` (close / remove) · `oa` (open padlock = open-access available) · `lock` (closed padlock = no free PDF) · `pending` (dashed circle = checking) · `book` (empty reading state) · `upload` (bulk import) · `eye` (colour-blind toggle). + +Typographic characters kept intentionally (NOT icons): `→` `←` `·` in prose/buttons. No em dashes anywhere. + +## 17. Global patterns + +- **Toast + undo:** bottom-centre, inverse surface (`--ink` bg / `--paper` text), one at a time, auto-dismiss 5s, always offers **Undo** for consequential changes. New toast replaces the current one. +- **Modal / confirm dialog:** scrim (`oklch(.2 .02 60/.5)`) + centred `--surface` card, `--sh-2`, title (serif) + body + footer actions right-aligned (Cancel secondary, destructive uses `--low` fill + `--on-accent`). Used for every destructive action (delete user, remove author with follow-data, reconnect source). +- **404:** full-bleed, in the product's calm voice, reassures nothing is broken, single primary action back to the dashboard. +- **Reader (non-admin) view:** admin-only surfaces are **hidden, not disabled** — Sources and Activity drop out of the nav; Users & roles, Sign-in and Source-credentials sections drop out of Settings. Toggle via "View as" (demo affordance; in production the role comes from the account). +- **Tooltips:** hover **and** keyboard-focus reveal (`.tip`/`.tip-pop`), used to explain status badges in place (replaces a static legend). Instant under reduced motion. +- **Colour-blind mode:** adds solid/dashed/dotted outlines to status badges so the three-stop scale is distinguishable without hue; toggle in the sidebar and in Settings → Accessibility; persisted in `localStorage` (`scholarr-cb`). Colour is never the only signal even with the mode off (every badge carries a word). + +## 18. Build note — support.js is tool-only + +The reference implementation `Scholarr.dc.html` loads a small runtime (`support.js`) that belongs to **the design tool only**. It is **not part of the production build and must not be shipped**. The Vue app reproduces the same markup and behaviour natively; every page must render correctly with no `support.js` present. The only runtime assets are `tokens.css`, the icon SVGs, the logos and the favicons. + +## 19. Vue handoff appendix + +**Theming strategy (pick one, this is it):** attribute strategy — `data-theme` on the root element, with `prefers-color-scheme` as the default when the attribute is absent (exactly as `tokens.css` is written). No component sets a raw colour; **all** colour/space/radius/shadow/motion flows through `var(--token)`. Scoped Vue ` + + + +
+
+ Scholarr · Component Reference + Theme + + + + +
+

Component Reference

+

Every component and its states, built entirely from tokens.css. Toggle Light/Dark and Colour-blind above to verify each in context. This page uses only CSS custom properties and a few vanilla-JS toggles; the production Vue app maps each block to the component named in DESIGN.md.

+ +

Colour tokens

+

Switch theme to see both palettes. Text tokens meet WCAG AA on their paired surfaces (see DESIGN.md §3).

+
+
--paper
--surface
--surface-2
--ink
--ink-soft
--ink-faint
--line
--line-strong
--accent
--accent-soft
--accent-ink
--hi
--hi-soft
--hi-ink
--mid
--mid-soft
--mid-ink
--low
--low-soft
--low-ink
+
+ +

Typography

+
+
Display · serif 700
+
Section title · serif 700
+
Body · sans 400. The retired academic reads here every day.
+
Secondary / meta · sans, ink-soft
+
doi:10.1177/09567976251234567 · mono, ink-faint
+
+ +

Buttons

+
Variants · default / hover / focus / active / disabled (hover & focus are live)
+
+ + + + + + +
+
+ +

Inputs & controls

+
+
+
Enter a valid email address.
+
+
Colour-blind badges
+
Select row
+
+
+ +

Badges — the two status vocabularies

+

Confidence and source health share one colour scale. Toggle Colour-blind to add outline patterns (solid / dashed / dotted).

+
+
Matching confidence
ConfidentLikelyUncertainNot yet identified
+
Source health
HealthyCooling downNot connected
+
Open access
OA checking none
+
Role
ReaderAdmin
+
+ +

Filter chips / tabs

+
All 14Unread 3NewFavoritesPsychology
+ +

List row — publication

+
+
+ +
PSYReappraisal flexibility predicts affective recovery
K. Ramaswamy · Psychological Science · 2026 · doi:10.1177/09567976251234567
+ OA +
+
+ +
PHYNonreciprocal magnon transport (read)
S. Andersson · Phys. Rev. Lett. · 2026
+ none +
+
+ +

Card / panel

+
Where data comes from
UnpaywallCooling down
+ +

Skeleton (loading)

+
+ +

Empty state

+
Nothing to read yet

When the researchers you follow publish, their work appears here.

+ +

Toast + undo

+
Lucas Bakker was removed.
+

Auto-dismiss after 5s. Carries an Undo for every consequential change. One toast at a time; new ones replace the old.

+ +

Modal — destructive confirmation

+
Remove Lucas Bakker?
This deletes their account and personal followed list. The shared publication pool is untouched.
+ +

Pagination

+
12325Showing 1–12 of 247
+ +

Mobile tab bar

+
HomeReadingAuthorsReviewMore
+ +

Icon set

+
+ dashboardpublicationsauthorsreviewsourcesactivitysettingssearchcheckcircledotplusexternalchev-leftchev-rightarrow-leftundosunmoonsystemsignoutwarningxoalockpendingbookuploadeye +
+ +

404

+
404
That page is not on the shelf

It may have been merged, removed, or never existed. Nothing is broken.

+
+ + + \ No newline at end of file diff --git a/design/reference/Scholarr.dc.html b/design/reference/Scholarr.dc.html new file mode 100644 index 0000000..98d5dfc --- /dev/null +++ b/design/reference/Scholarr.dc.html @@ -0,0 +1,1078 @@ + + + + + + + + + + + + + +
+ + + + +
+ + +
+ Scholarr + {{ mobileTitle }} + +
+
+ + + +
+
+
+
+
+
Good morning, Herman
+

{{ newCount }} new publications since your last visit

+
from {{ newAuthorCount }} of the {{ followCount }} researchers you follow · {{ unreadCount }} unread in total
+
+ Up to date · {{ lastChecked }} +
+
+
+ New work from +
+ + + +
+ +
+
+ +
+
+

Fresh in your feed

+
+
+ + +
+ +
+
+
+
+ +
+
+

You are all caught up

+

Nothing new since your last visit. We will keep checking your sources on schedule.

+
+
+ +
+ +
+
{{ p.fieldShort }}{{ p.title }}
+
{{ p.authors }} · {{ p.venue }} · {{ p.year }}
+
{{ p.oaLabel }}{{ p.oaTitle }}{{ p.ident }} · via {{ p.source }}
+
+
+
+ +
+ +

All sources checked {{ lastChecked }}. arXiv can run a day or two behind. That is normal, and nothing is missing.

+
+
+ + + +
+
+
+

Publications

+

{{ pubLibraryTotal }} in your library · {{ newCount }} new · {{ unreadCount }} unread

+
+
last delivered {{ pubFreshness }}
+
+
+
+ + + +
+
+ + Search author or title +
+
+
+ Preview state + +
+ + +
+ +
+
+
+
+
+
+
+
+ +
+
+

Nothing to read yet

+

When the researchers you follow publish, their work will appear here. Follow a few more people to widen your feed.

+ +
+
+ +
+ {{ p.readTip }} +
+
+ {{ p.fieldShort }} +

{{ p.title }}

+
+
{{ p.authors }} · {{ p.venue }} · {{ p.year }} · {{ p.ident }} · {{ p.source }}
+
+ NEWArrived since your last visit. + {{ p.oaShort }}{{ p.oaTitle }} + + +
+
+
+
+ + + +
+
+
+

Authors you follow

+

247 followed · {{ shellCount }} not yet identified · {{ reviewCount }} awaiting review

+
+ +
+
+
+ +
+
+ Search authors +
+
+ + +
+ {{ selCount }} selected + + + +
+
+ +
+ Preview state + +
+ +
+ + Author + Field + Identity + Last sync + New +
+
+ + +
+ + +
+ +
+
+
+ +
+
+

You are not following anyone yet

+

Search for a researcher by name, or bring in your whole list at once. Scholarr matches each to a database identity before anything is followed.

+
+ + +
+
+
+ +
+ + + {{ a.field }} + {{ a.confDot }}{{ a.confLabel }}{{ a.confTitle }} + {{ a.lastSync }} + +{{ a.newN }} +
+
+

Showing 12 of 247 followed authors

+
+
+ + + +
+ +
Preview state
+
+
+
+ {{ detail.initials }} +
+

{{ detail.name }}

+
{{ detail.nonlatin }} · {{ detail.field }} · {{ detail.works }} works
+
+
+
+

Publications

+ {{ detail.pubCount }} in your library +
+
No publications from this author are in your library yet. New work will appear here after the next sync.
+
+ +
+
{{ p.title }}
+
{{ p.venue }} · {{ p.year }} · {{ p.ident }}
+
+ {{ p.oaShort }}{{ p.oaTitle }} +
+
+
+ + +
+
+
+ + + +
+
+

Review queue

+

Resolve one identity question at a time. Nothing changes until you decide, and every decision can be undone.

+
+ + +
+
+

Queue is clear

+

Every author has been identified. New ambiguous matches will appear here as they arrive.

+ +
+
+ + +
+
+ {{ reviewPos }} of {{ reviewTotal }} + +
+ +
+
+ {{ review.typeLabel }} +

{{ review.title }}

+
+
+

{{ review.prompt }}

+
+ + + +
+
+
+ + + 123 chooseS skipU undoJ/K move +
+
+
+
+
+ + + +
+
+

Add an author

+

Search by name, then confirm which researcher you mean. Following hundreds at once?

+
+
Preview state
+
+ + {{ addQuery }} + {{ addCountLabel }} +
+
+ +

Several researchers publish under this name. Compare affiliation, field and recent work, then pick the one you follow.

+
+

ORCID is cooling down right now, so identity confidence may read lower than usual. You can still follow the best match; its confidence firms up automatically once ORCID resumes.

+

No researcher found

Nothing in OpenAlex or ORCID matches that name. Check the spelling, try the full name, or paste their Scholar profile URL instead.

+
+ +
+
+ {{ c.initials }} +
+
{{ c.name }}{{ c.confDot }}{{ c.conf }}
+
{{ c.affil }} · {{ c.field }}
+
+ {{ c.works }} works + since {{ c.since }} + {{ c.ids }} +
+
+
Recent work
+
“{{ s.t }}” · {{ s.v }}
+
+
+ +
+
+
+
+ +
+ Now following {{ addConfirmedName }}. Their back catalogue is being imported. + +
+
+
+
+ + + +
+
+

Bulk import authors

+

Paste a list of Scholar profile URLs or upload an export. We match each to a database identity. Nothing is followed until you approve it.

+
+
+
{{ s.n }}{{ s.label }}
+
+ + +
+
Paste profile URLs, one per line
+
scholar.google.com/citations?user=aB1cD2e +scholar.google.com/citations?user=Xy9Zk3m +scholar.google.com/citations?user=Qp4Rt7n +… 244 more lines
+
+
We checked your 247 lines before importing anything
+
+ 241 look valid and are ready to match + 3 could not be read (not a Scholar profile link) — lines 44, 108, 231 + 2 are duplicates within your list — we will keep one of each + 1 is already in your followed list and will be skipped +
+
+
+ + or + +
+
+
+ + +
+
Matching against OpenAlex & ORCID…
+
+
156 of 247 resolved · Unpaywall paused (cooling down)
+ +
+
+ + +
+
198
Confident matches
Ready to follow automatically
+
33
Likely, need a look
One tap each in review
+
16
Uncertain or shells
Kept aside for review
+
+
+

Preview · confident matches

showing 4 of 198
+
{{ b.initials }}{{ b.name }}{{ b.affil }}{{ b.confDot }}{{ b.conf }}
+
+
+ + + Safe by default: nothing is followed and no duplicates are created until you confirm. You can undo the whole import from Activity. +
+ +
Now following 198 new authors. 49 remain in the review queue.
+
+
+
+
+ + + +
+
+

Sources

+

Where Scholarr gets its facts. Health and freshness are shown honestly. Publication data lags reality by days to weeks; that is expected.

+
+
Preview state
+
+
+

No sources connected yet

Connect a data source in Settings to start delivering publications.

+ +
+
+ +

{{ s.name }}

+ {{ s.stateLabel }}{{ s.tip }} + last delivered {{ s.last }} +
+

{{ s.provides }}

+

{{ s.note }}

+
+
+
+
+
+ + + +
+
+

Activity

+

Every sync run and its outcome. Runs happen on a schedule and when you trigger them.

+
+
+ +
+ +
+
{{ r.title }}
+
{{ r.detail }}
+
+ {{ r.outcome }} + {{ r.when }} +
+
+
+
+
+ + + +
+

Settings

+
+
+

Your profile

+
Display name
Herman Bakker
Email
h.bakker@example.org
+
Your changes have been saved.
Fields marked * are required. Changes are validated when you save.
+
+ +
+

Accessibility

+
+
+
+
Colour-blind friendly badges
+
Adds a distinct outline to each status badge (solid, dashed, dotted) so confidence and source health can be told apart without relying on colour. Every badge already carries a word as well.
+
+ +
+
+
+
Theme
+
Follows your device by default. Light and dark are built from one token set and both meet WCAG AA contrast. Reduced motion is respected automatically.
+
+ System +
+
+
+ + +
+

Users & roles

Admin only
+
{{ u.initials }}{{ u.name }}{{ u.email }}{{ u.role }}
+
+
+ +
+

Sign-in

+
+
Username & password
On. Accounts are created by an admin. Passwords stay on this server.
Enabled
+
Single sign-on (OIDC)
On. People sign in with your organisation account. Connected to “university-idp”.
Enabled
+
Trusted proxy
Off. If your server sits behind a proxy that already signs people in, Scholarr can skip its own login.
Disabled
+
+
+ +
+

Source credentials & schedule

+
+ + + +
+
+
+
+
+
+
+ + + +
+
+
+ Scholarr +

Scholarr

+

Sign in to your instance

+
+
+
+
That username or password was not recognised. Please try again.
+ + + + +
or
+
+

This instance uses single sign-on. There is no local password to enter.

+ +
+

Fresh install?
Some installs sign you in automatically via a trusted proxy.

+
+
+
+ + + +
+
+
+ Scholarr +
Set up Scholarr
Step 3 of 4 · Add your first authors
+
+
+
+
+
+

Who do you want to follow?

+

Add a few researchers by name now, or bring in your whole list at once. You can always add more later.

+
Search a researcher by name…
+
following hundreds already?
+ +
+ + You will land on a dashboard that explains what happens next. +
+
+
+
+
+
+ + +
+
+
404
+

That page is not on the shelf

+

We could not find what you were looking for. It may have been merged, removed, or never existed. Nothing is broken, and your library is safe.

+ +
+
+
+ +
+
+

{{ modalTitle }}

+

{{ modalBody }}

+
+ + +
+
+
+
+ +
+ {{ toastMsg }} + +
+
+ +
+
+ + + diff --git a/design/reference/support.js b/design/reference/support.js new file mode 100644 index 0000000..dc9a1d5 --- /dev/null +++ b/design/reference/support.js @@ -0,0 +1,1841 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const kids = hasContent ? walkChildren(el, host) : []; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...kids.map((b, j) => b(vals, ctx, j))); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ")") { + if (css[j] === "\\") j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ""; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") { + out += c + (css[i + 1] ?? ""); + i++; + continue; + } + if (c === quote) quote = ""; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + i = end === -1 ? css.length : end + 1; + out += " "; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(") depth++; + else if (c === ")") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const isPseudoElement = pseudo === "before" || pseudo === "after"; + const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule( + sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: