diff --git a/AGENTS.md b/AGENTS.md index 064b8cf..82b9e79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,12 @@ leave documentation describing planned behavior as if it already exists. palettes, and path construction. It contains no Svelte or browser UI state. - `src/lib/identity.ts` validates friendly handles and resolves them to canonical DIDs without changing the generator's DID-only input contract. +- `src/lib/oauth-config.ts` owns the discoverable production client ID, callback, handle resolver, + least-privilege scope, and loopback client construction. Static metadata and tests must agree. +- `src/lib/oauth.ts` dynamically loads the official browser OAuth client, restores/revokes its + IndexedDB-backed sessions, and exposes the authenticated `Agent` through a Svelte store. +- `src/lib/collection.ts` validates collection records and owns bounded list, confirmed create, and + exact-rkey delete operations. Treat loaded records and PDS responses as untrusted. - `src/lib/export.ts` serializes portable specimen SVGs and provenance metadata. Export changes must preserve well-formed XML and keep subject DIDs escaped as untrusted text. - `src/lib/shape.test.ts` protects deterministic output, validation, and trait bounds. @@ -54,7 +60,11 @@ leave documentation describing planned behavior as if it already exists. - `src/lib/components/Specimen.svelte` renders a `Specimen` as labelled, accessible SVG. It must not independently reinterpret digest bytes. - `src/routes/+page.svelte` coordinates the observation form, example cabinet, local study tray, - loading/error state, and page copy. + signed-in PDS collection shortcut, loading/error state, and page copy. +- `src/routes/profile/+page.svelte` is the OAuth callback and curator profile. It displays the + signed-in DID specimen and manages that repository's Hasharium collection entries. +- `src/routes/about/+page.svelte` is the public OAuth policy/terms URI and must accurately describe + storage, permissions, third-party resolution, public records, and availability. - `src/routes/styles.css` is the site-wide visual system. There is intentionally no component library or utility-CSS framework. - `lexicons/click/croft/hasharium/` contains the source lexicons. The directory mirrors each @@ -111,12 +121,26 @@ Rules: ## AT Protocol and authentication boundaries -The checked-in prototype resolves handles through Microcosm Slingshot but does not yet authenticate -or write repository records. Until that work lands, the study tray is explicitly browser-local and -must not imply that it follows the user. Copy must disclose that handle resolution is a network -request while direct DID generation remains local. +The application uses `@atproto/oauth-client-browser` as a static public client. It resolves handles +through Microcosm Slingshot, completes OAuth at `/profile`, stores browser sessions in the SDK's +IndexedDB database, and reads/writes confirmed collection-entry records in the signed-in user's PDS. +The study tray remains explicitly browser-local and separate from the PDS profile cabinet. Copy must +disclose that handle resolution is a network request while direct DID generation remains local. -When implementing the PDS-backed milestone: +Current OAuth invariants: + +- Production client ID: `https://hasharium.croft.click/oauth-client-metadata.json`. +- Production redirect URI: `https://hasharium.croft.click/profile`. +- Requested scope: `atproto` plus one granular `repo:` scope for each of collection entry, + intersection, and exhibition. Never replace these with `repo:*` or an unrelated namespace. +- Client type: public web client with authorization code, refresh tokens, PKCE, and DPoP-bound + access tokens. Never add a browser client secret. +- Development uses the AT Protocol loopback client convention with a `127.0.0.1` redirect; do not + replace it with `localhost` in the redirect URI. +- Metadata scope is the maximum grant and runtime sign-in asks for the same namespace-bounded scope. Keep + `oauth-config.ts`, `static/oauth-client-metadata.json`, tests, `/about`, and this guidance aligned. + +When extending authenticated PDS behavior: - Prefer browser OAuth using current AT Protocol OAuth guidance. Do not add app-password login as a shortcut. @@ -132,6 +156,8 @@ When implementing the PDS-backed milestone: Do not inspect or expose existing browser credentials while debugging. - A write is successful only after the repository confirms it. Surface pending, success, failure, and retry states accurately; never optimistically claim a collection was saved. +- Revocation/sign-out errors must not leave the UI claiming an authenticated session. Never log or + render access tokens, refresh tokens, authorization codes, DPoP keys, or raw callback fragments. - Prevent accidental duplicate entries in normal UI, but tolerate duplicate public records when reading because repositories are user-controlled. - Validate every loaded record before rendering. Ignore unknown `$type`s, malformed dates, @@ -174,6 +200,10 @@ When implementing the PDS-backed milestone: Avoid generic gradient SaaS cards, neon crypto aesthetics, glassmorphism, excessive pills, and dashboards full of metrics. - Let the specimens provide most of the colour. Interface chrome should remain restrained. +- Hasharium is the natural-history expression of the wider Croft design language: preserve the + shared 4pt spacing rhythm, Inter/system UI register, mono technical labels, earthy OKLCH palette, + stable colour-based editorial-row interaction, strong focus treatment, and purposeful easing. + Its display serif, paper, oxide, plates, rules, and drawer metaphor remain intentionally unique. - Use code-native SVG/CSS for canonical shapes, diagrams, marks, and icons. Do not rasterize the generator output or require an image-generation service to render identity forms. - Use real semantic controls. Cards that select specimens are buttons; destinations are links. @@ -187,8 +217,9 @@ When implementing the PDS-backed milestone: wrap without widening the page. Dialog content must remain reachable on short screens. - The browser-local tray key is `hasharium.study-tray`. Treat its JSON as untrusted: catch parse failures and filter values. A format change requires tolerant migration or an explicit reset. -- Avoid runtime third-party font, analytics, and asset requests. The prototype deliberately uses - local system font stacks and computes specimens entirely in the browser. +- Avoid runtime third-party font, analytics, and asset requests. Inter and JetBrains Mono are + self-hosted in the build, the display serif uses a local system stack, and specimens are + computed entirely in the browser. Preserve the visible font licence notice when changing fonts. ## Svelte and code rules @@ -268,6 +299,10 @@ UI changes additionally require manual checks for: names/status, reduced motion, narrow mobile, tablet, desktop, and high zoom; - no unexpected external requests, console errors, horizontal overflow, clipped dialog content, or false claims of PDS persistence; +- OAuth metadata fetch, handle/DID sign-in, callback cleanup, session restore, revocation, denied and + failed authorization, and a callback reload that does not exchange the same code twice; +- PDS collection list/create/delete with malformed records, duplicates, cursor failure, rejected + writes, exact delete keys, and the public-note disclosure; - production output served from the same kind of static hosting intended for `hasharium.croft.click`, including direct/fallback routes and favicon/metadata. @@ -308,11 +343,10 @@ Unless the user prioritizes another slice, develop in this order: 1. Harden v1 golden compatibility tests and exportable SVG metadata. 2. Add handle-to-DID resolution while continuing to hash only the resolved DID. -3. Implement narrowly scoped AT Protocol OAuth for `hasharium.croft.click`. -4. Replace/augment the local study tray with confirmed - `click.croft.hasharium.collection.entry` writes and public repository reads. -5. Add deterministic pair intersections with reciprocal-status evidence. -6. Add authored exhibitions and public discovery through verified records/backlinks. +3. Add deterministic pair intersections with reciprocal-status evidence. +4. Add authored exhibitions and public discovery through verified records/backlinks. +5. Consider replacing the three explicit same-namespace scopes with a published Hasharium + permission set once that improves authorization descriptions without broadening access. Each milestone must preserve a useful signed-out observation experience. Authentication should enhance collecting, not gate the core act of seeing a DID's form. diff --git a/README.md b/README.md index 7d6284e..88f2cd6 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,16 @@ The public prototype includes: - standalone SVG export with subject, fingerprint, catalogue, and generator metadata; - a specimen label and morphological traits; - a curated public cabinet of example identities; -- a browser-local study tray; +- a browser-local signed-out study tray; +- AT Protocol OAuth using the official browser client, PKCE, DPoP, and refresh-token rotation; +- a curator profile with the signed-in identity's specimen and PDS-backed collection cabinet; +- confirmed creation and removal of `click.croft.hasharium.collection.entry` records; - static output suitable for deployment at `hasharium.croft.click`; - initial AT Protocol lexicons under the required `click.croft.hasharium.*` namespace. -OAuth, PDS record writes, public collection loading, intersections, and exhibitions are -deliberately not presented as working yet. The local study tray is a preview of the eventual signed -collection experience. +Intersections, exhibitions, and network-wide public collection discovery are deliberately not +presented as working yet. Signed-out observations remain useful and private to the device; signed-in +collection changes complete only after the visitor's PDS confirms them. ## Development @@ -35,6 +38,10 @@ pnpm install pnpm dev ``` +The development OAuth client uses the AT Protocol loopback convention and redirects to +`http://127.0.0.1:5173/profile`. Keep Vite on port 5173 when manually exercising the development +flow. Production uses the discoverable metadata at `/oauth-client-metadata.json`. + Quality gates: ```sh @@ -53,9 +60,14 @@ The static production output is written to `build/`. src/lib/shape.ts SHA-256-to-SVG renderer and morphology src/lib/identity.ts DID input and bounded handle resolution src/lib/export.ts standalone SVG and provenance metadata export +src/lib/oauth-config.ts production/loopback OAuth identifiers and bounded scopes +src/lib/oauth.ts browser OAuth session lifecycle and authenticated Agent +src/lib/collection.ts validated collection reads, confirmed writes, and removals src/lib/protocol.ts canonical host, NSIDs, and protocol constants src/lib/components/Specimen.svelte accessible SVG presentation src/routes/+page.svelte observation, cabinet, and study-tray interaction +src/routes/profile/+page.svelte OAuth entry point and PDS-backed curator profile +src/routes/about/+page.svelte method, privacy, permission, and service terms src/routes/styles.css complete visual system and responsive layout lexicons/click/croft/hasharium/ AT Protocol lexicon sources ``` @@ -74,6 +86,26 @@ All Hasharium records live below `click.croft.hasharium.*`: Lexicons describe public repository data. Notes are therefore public, despite their personal curatorial character. See `AGENTS.md` before changing schemas or implementing authentication. +## OAuth and privacy + +Hasharium is a static public OAuth client. It requests identity plus granular repository access to +every published Hasharium record type: + +```text +atproto repo:click.croft.hasharium.collection.entry repo:click.croft.hasharium.exhibition repo:click.croft.hasharium.intersection +``` + +The official `@atproto/oauth-client-browser` package performs authorization-code exchange, PKCE, +DPoP, refresh, revocation, and IndexedDB session storage. Hasharium never accepts app passwords and +does not receive access outside `click.croft.hasharium.*`, blobs, email, or account administration. +Handle resolution uses Microcosm Slingshot and therefore makes a disclosed network request; direct +shape generation remains local. Collection records and optional field notes are public in the +author's repository. + +Inter and JetBrains Mono are packaged into the production build from Fontsource; the browser does +not contact a font CDN. The cabinet's display serif remains a deliberately system-native contrast. +Their SIL Open Font License notices are published at `/font-licenses.txt`. + ## Deployment Hasharium is configured with SvelteKit's static adapter and a `404.html` fallback. Deploy the @@ -88,9 +120,9 @@ its server-rendered SvelteKit defaults to this static-adapter project. artifact. The `CNAME` file directly supports GitHub Pages; other hosts may ignore it and require their own domain configuration. -Before enabling AT Protocol OAuth, add production client metadata, exact redirect URIs for -`https://hasharium.croft.click`, a narrowly scoped repository permission, and real callback and -session-restoration tests. +Production OAuth depends on the metadata document, `/profile` callback route, and application +origin remaining available at their exact HTTPS URLs. Changing the collection NSID, redirect URI, +or scope requires updating runtime constants, static metadata, tests, and deployed files together. ## Licence diff --git a/package.json b/package.json index b1a70c1..3a03fe4 100644 --- a/package.json +++ b/package.json @@ -28,5 +28,11 @@ "typescript": "^6.0.2", "vite": "^8.0.8", "vitest": "^4.1.0" + }, + "dependencies": { + "@atproto/api": "0.20.30", + "@atproto/oauth-client-browser": "0.4.9", + "@fontsource-variable/inter": "5.2.8", + "@fontsource-variable/jetbrains-mono": "5.2.8" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7336bda..d64214a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,19 @@ settings: importers: .: + dependencies: + "@atproto/api": + specifier: 0.20.30 + version: 0.20.30 + "@atproto/oauth-client-browser": + specifier: 0.4.9 + version: 0.4.9 + "@fontsource-variable/inter": + specifier: 5.2.8 + version: 5.2.8 + "@fontsource-variable/jetbrains-mono": + specifier: 5.2.8 + version: 5.2.8 devDependencies: "@sveltejs/adapter-static": specifier: ^3.0.10 @@ -42,6 +55,153 @@ importers: version: 4.1.10(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)) packages: + "@atproto-labs/did-resolver@0.3.5": + resolution: + { + integrity: sha512-0dMM+hj40VQiD/EJhlC1UMQgPXRwKeqM7NgJte7fVuYMv5b3P0W6+Lu3iDumHULcSjMmMXxJZzoi3i493Y0gCA==, + } + engines: { node: ">=22" } + + "@atproto-labs/fetch@0.3.4": + resolution: + { + integrity: sha512-YxXwi8HMk2HHDd5rljPGqxZ8bSeU78sFNz511Y222D0FraEq98p2r1ifkIgI23KQOfy0k0VzafeJAiGDh1CvfQ==, + } + engines: { node: ">=22" } + + "@atproto-labs/handle-resolver@0.4.6": + resolution: + { + integrity: sha512-a3ZoQ0xpIowFuIhvjXwaloJwT3He6NJ02FfrKaRW0tdT2GvEH6nLaVHr46ps8A4ddCz+Q4oUha9atfr4O+0P2g==, + } + engines: { node: ">=22" } + + "@atproto-labs/identity-resolver@0.4.5": + resolution: + { + integrity: sha512-N/v4vZ4z8hFkudBKe5S1MIUP4nI+Qrr2FVVoGJ1ED+j4tT05y6JB8XFC34jgdZUaeQhi5wLaaSMoqUD269S6tw==, + } + engines: { node: ">=22" } + + "@atproto-labs/pipe@0.2.4": + resolution: + { + integrity: sha512-n67jCcrC+ouAeO10cWkpPzzLMlDi/lDCU30Us+LGqhOPhT6c4t5ASdBLQi9W3jUQtRzQBt3G9zipF+xKWNvVbw==, + } + engines: { node: ">=22" } + + "@atproto-labs/simple-store-memory@0.2.4": + resolution: + { + integrity: sha512-xAAUlOP9etqP9GGmJPq9gY1bRuJlZFdsC6wtAJSwUwMTwLRdenR7s8Qg5nxhbth5XL1XOtGABoD59NW3p+sl4A==, + } + engines: { node: ">=22" } + + "@atproto-labs/simple-store@0.4.4": + resolution: + { + integrity: sha512-3YH03xg99ZUS6Fq/6jcKRiZwUbzDb58xmS7ibg0y4+dukwrHa92cV/md76RpQJkAo9KYBvwLETdvF6fNtwIoGw==, + } + engines: { node: ">=22" } + + "@atproto/api@0.20.30": + resolution: + { + integrity: sha512-t/9pPW+60AFeefiT186z4Z16yz4UmNGtb0EYVyCIHkwYk6nat57OvJA3oEbp4NK9yd/REsVCTBk6gOk8PNWyQQ==, + } + engines: { node: ">=22" } + + "@atproto/common-web@0.5.6": + resolution: + { + integrity: sha512-5Y4MIK9dpkJPiKiE6u7iEHitxj+g3aAU2GfGL686JlKE2zDKD4y18BJb+uVek6nXQKb5XOdNVvw+7BHarpn8Fw==, + } + engines: { node: ">=22" } + + "@atproto/did@0.5.4": + resolution: + { + integrity: sha512-BlnwQ+obL+4ZA71KH/EzZ3TY+cpSxnLiUI85mjBJIQwvDF/oN2sQA18wIj9jpduviIt2b/cMtlJuzHjzBkfXvw==, + } + engines: { node: ">=22" } + + "@atproto/jwk-jose@0.2.4": + resolution: + { + integrity: sha512-gzDoA0JTwnc0ZJOBLM7WX9xFxtynRS2K1Bofb8epzoMWDQvyvfbPcfkdPrKFM7NXCFUVpGpBnsCB8KFPTf1rCg==, + } + engines: { node: ">=22" } + + "@atproto/jwk-webcrypto@0.3.4": + resolution: + { + integrity: sha512-UsFIUozqnRecXPo6HgKV4PW4FqYHxX1V3iAe0rRV6Q2RSfYD8V2mZ89pv8NpvJynnaqJArBQ7HZlcg0F4tRYhA==, + } + engines: { node: ">=22" } + + "@atproto/jwk@0.7.4": + resolution: + { + integrity: sha512-tq7TUDmNfe1yDfpRgdGQMJdl9TUlJmREQNCag9yg5w8Evu+TOiFiLgiOCbo7X4ouRPSgd1DpOzXbUa8UyKKMZA==, + } + engines: { node: ">=22" } + + "@atproto/lex-data@0.1.5": + resolution: + { + integrity: sha512-TEM6GHuYpNm4O90LjNgbYq1Gmcr875S+BHrDxkg4PB5w/nlsz4HlbkGQG/WP/xbIV5O8TF5nNHDpCZ5ezTRrFA==, + } + engines: { node: ">=22" } + + "@atproto/lex-json@0.1.4": + resolution: + { + integrity: sha512-ENR2cWkVrES+UL6TovbCRdX9BJOyHHJUS8jYx3Lxp3j4vEphjn/u+DW7bWloST2O8ID2OuVlt6+28ftNEEjmQQ==, + } + engines: { node: ">=22" } + + "@atproto/lexicon@0.7.7": + resolution: + { + integrity: sha512-92VH2oEsJdrIVNy7WY8rGn99ANNVglyUffoN7GJc0mxKi+fXN5iVlJ2cOyTfYABhr2ldSiptZWXEPEdBaIR9/A==, + } + engines: { node: ">=22" } + + "@atproto/oauth-client-browser@0.4.9": + resolution: + { + integrity: sha512-97sZG7vacmlPlSVmHHWgPoBDhQukDvt7ajwexrg5FVW3Y8ngIIXCUQOSas5vUTyl16SUmsDf7WtGMsjPjNkFdg==, + } + engines: { node: ">=22" } + + "@atproto/oauth-client@0.7.11": + resolution: + { + integrity: sha512-kCoQxT2CXEKT2LCZA3nZFd5VjbZN6WQ7z8asUfRICuhm79Q/Ovbs8eFftdcxM75O7M/9/EdcZ6Opppi0cq5kjw==, + } + engines: { node: ">=22" } + + "@atproto/oauth-types@0.7.5": + resolution: + { + integrity: sha512-x75O0HsKB1IGfBikAQrrTX6EL8Rt4Q0+wMcwhZQRGPk/N/WqbYbsW3Powj4R8ZJKSfWCpVfaw32Piu1pTi891Q==, + } + engines: { node: ">=22" } + + "@atproto/syntax@0.7.2": + resolution: + { + integrity: sha512-tZ1Tr0R9pK4bI4Zs69t29cjMlCFQvRNBeNkqJC5pGeNuCt64D0eoF7s/AlqeVmYppClmQ0xJquggGgsMDP6j7w==, + } + engines: { node: ">=22" } + + "@atproto/xrpc@0.8.6": + resolution: + { + integrity: sha512-yVfKrlwZBBm44Ft9jDvHcTCwQ1ElqSlzXHWhT/KcqE+u24EAhWVFosfABfGbUByB/PmN8eLJ06FsWo5pUQcMNQ==, + } + engines: { node: ">=22" } + "@emnapi/core@1.11.1": resolution: { @@ -60,6 +220,18 @@ packages: integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==, } + "@fontsource-variable/inter@5.2.8": + resolution: + { + integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==, + } + + "@fontsource-variable/jetbrains-mono@5.2.8": + resolution: + { + integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==, + } + "@jridgewell/gen-mapping@0.3.13": resolution: { @@ -424,6 +596,12 @@ packages: } engines: { node: ">=12" } + await-lock@3.0.0: + resolution: + { + integrity: sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA==, + } + axobject-query@4.1.0: resolution: { @@ -465,6 +643,12 @@ packages: } engines: { node: ">= 0.6" } + core-js@3.49.0: + resolution: + { + integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==, + } + deepmerge@4.3.1: resolution: { @@ -547,6 +731,18 @@ packages: integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==, } + iso-datestring-validator@2.2.2: + resolution: + { + integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==, + } + + jose@5.10.0: + resolution: + { + integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==, + } + kleur@4.1.5: resolution: { @@ -666,6 +862,12 @@ packages: integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==, } + lru-cache@10.4.3: + resolution: + { + integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==, + } + magic-string@0.30.21: resolution: { @@ -686,6 +888,12 @@ packages: } engines: { node: ">=10" } + multiformats@13.4.2: + resolution: + { + integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==, + } + nanoid@3.3.16: resolution: { @@ -849,6 +1057,13 @@ packages: } engines: { node: ">=14.0.0" } + tlds@1.261.0: + resolution: + { + integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==, + } + hasBin: true + totalist@3.0.1: resolution: { @@ -876,6 +1091,12 @@ packages: integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==, } + unicode-segmenter@0.14.5: + resolution: + { + integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==, + } + vite@8.1.5: resolution: { @@ -991,7 +1212,147 @@ packages: integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==, } + zod@3.25.76: + resolution: + { + integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, + } + snapshots: + "@atproto-labs/did-resolver@0.3.5": + dependencies: + "@atproto-labs/fetch": 0.3.4 + "@atproto-labs/pipe": 0.2.4 + "@atproto-labs/simple-store": 0.4.4 + "@atproto-labs/simple-store-memory": 0.2.4 + "@atproto/did": 0.5.4 + zod: 3.25.76 + + "@atproto-labs/fetch@0.3.4": + dependencies: + "@atproto-labs/pipe": 0.2.4 + + "@atproto-labs/handle-resolver@0.4.6": + dependencies: + "@atproto-labs/simple-store": 0.4.4 + "@atproto-labs/simple-store-memory": 0.2.4 + "@atproto/did": 0.5.4 + zod: 3.25.76 + + "@atproto-labs/identity-resolver@0.4.5": + dependencies: + "@atproto-labs/did-resolver": 0.3.5 + "@atproto-labs/handle-resolver": 0.4.6 + + "@atproto-labs/pipe@0.2.4": {} + + "@atproto-labs/simple-store-memory@0.2.4": + dependencies: + "@atproto-labs/simple-store": 0.4.4 + lru-cache: 10.4.3 + + "@atproto-labs/simple-store@0.4.4": {} + + "@atproto/api@0.20.30": + dependencies: + "@atproto/common-web": 0.5.6 + "@atproto/lexicon": 0.7.7 + "@atproto/syntax": 0.7.2 + "@atproto/xrpc": 0.8.6 + await-lock: 3.0.0 + multiformats: 13.4.2 + tlds: 1.261.0 + zod: 3.25.76 + + "@atproto/common-web@0.5.6": + dependencies: + "@atproto/lex-data": 0.1.5 + "@atproto/lex-json": 0.1.4 + "@atproto/syntax": 0.7.2 + zod: 3.25.76 + + "@atproto/did@0.5.4": + dependencies: + zod: 3.25.76 + + "@atproto/jwk-jose@0.2.4": + dependencies: + "@atproto/jwk": 0.7.4 + jose: 5.10.0 + + "@atproto/jwk-webcrypto@0.3.4": + dependencies: + "@atproto/jwk": 0.7.4 + "@atproto/jwk-jose": 0.2.4 + zod: 3.25.76 + + "@atproto/jwk@0.7.4": + dependencies: + multiformats: 13.4.2 + zod: 3.25.76 + + "@atproto/lex-data@0.1.5": + dependencies: + multiformats: 13.4.2 + tslib: 2.8.1 + unicode-segmenter: 0.14.5 + + "@atproto/lex-json@0.1.4": + dependencies: + "@atproto/lex-data": 0.1.5 + tslib: 2.8.1 + + "@atproto/lexicon@0.7.7": + dependencies: + "@atproto/common-web": 0.5.6 + "@atproto/syntax": 0.7.2 + multiformats: 13.4.2 + zod: 3.25.76 + + "@atproto/oauth-client-browser@0.4.9": + dependencies: + "@atproto-labs/did-resolver": 0.3.5 + "@atproto-labs/handle-resolver": 0.4.6 + "@atproto-labs/simple-store": 0.4.4 + "@atproto/did": 0.5.4 + "@atproto/jwk": 0.7.4 + "@atproto/jwk-webcrypto": 0.3.4 + "@atproto/oauth-client": 0.7.11 + "@atproto/oauth-types": 0.7.5 + core-js: 3.49.0 + + "@atproto/oauth-client@0.7.11": + dependencies: + "@atproto-labs/did-resolver": 0.3.5 + "@atproto-labs/fetch": 0.3.4 + "@atproto-labs/handle-resolver": 0.4.6 + "@atproto-labs/identity-resolver": 0.4.5 + "@atproto-labs/simple-store": 0.4.4 + "@atproto-labs/simple-store-memory": 0.2.4 + "@atproto/did": 0.5.4 + "@atproto/jwk": 0.7.4 + "@atproto/oauth-types": 0.7.5 + "@atproto/xrpc": 0.8.6 + core-js: 3.49.0 + multiformats: 13.4.2 + zod: 3.25.76 + + "@atproto/oauth-types@0.7.5": + dependencies: + "@atproto/did": 0.5.4 + "@atproto/jwk": 0.7.4 + zod: 3.25.76 + + "@atproto/syntax@0.7.2": + dependencies: + iso-datestring-validator: 2.2.2 + tslib: 2.8.1 + + "@atproto/xrpc@0.8.6": + dependencies: + "@atproto/lexicon": 0.7.7 + zod: 3.25.76 + "@emnapi/core@1.11.1": dependencies: "@emnapi/wasi-threads": 1.2.2 @@ -1008,6 +1369,10 @@ snapshots: tslib: 2.8.1 optional: true + "@fontsource-variable/inter@5.2.8": {} + + "@fontsource-variable/jetbrains-mono@5.2.8": {} + "@jridgewell/gen-mapping@0.3.13": dependencies: "@jridgewell/sourcemap-codec": 1.5.5 @@ -1199,6 +1564,8 @@ snapshots: assertion-error@2.0.1: {} + await-lock@3.0.0: {} + axobject-query@4.1.0: {} chai@6.2.2: {} @@ -1213,6 +1580,8 @@ snapshots: cookie@0.6.0: {} + core-js@3.49.0: {} + deepmerge@4.3.1: {} detect-libc@2.1.2: {} @@ -1244,6 +1613,10 @@ snapshots: dependencies: "@types/estree": 1.0.9 + iso-datestring-validator@2.2.2: {} + + jose@5.10.0: {} + kleur@4.1.5: {} lightningcss-android-arm64@1.32.0: @@ -1297,6 +1670,8 @@ snapshots: locate-character@3.0.0: {} + lru-cache@10.4.3: {} + magic-string@0.30.21: dependencies: "@jridgewell/sourcemap-codec": 1.5.5 @@ -1305,6 +1680,8 @@ snapshots: mrmime@2.0.1: {} + multiformats@13.4.2: {} + nanoid@3.3.16: {} obug@2.1.4: {} @@ -1416,15 +1793,18 @@ snapshots: tinyrainbow@3.1.0: {} + tlds@1.261.0: {} + totalist@3.0.1: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} typescript@6.0.3: {} undici-types@8.3.0: {} + unicode-segmenter@0.14.5: {} + vite@8.1.5(@types/node@26.1.1): dependencies: lightningcss: 1.32.0 @@ -1473,3 +1853,5 @@ snapshots: stackback: 0.0.2 zimmerframe@1.1.4: {} + + zod@3.25.76: {} diff --git a/src/lib/collection.test.ts b/src/lib/collection.test.ts new file mode 100644 index 0000000..110ab11 --- /dev/null +++ b/src/lib/collection.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { GENERATOR_VERSION, NSID } from "./protocol"; +import { + countGraphemes, + parseCollectionEntry, + parseCollectionEntryRecord, + recordKeyFromUri, + validateCollectionNote, +} from "./collection"; + +const validRecord = { + $type: NSID.collectionEntry, + subject: "did:plc:ofrbh253gwicbkc5nktqepol", + generatorVersion: GENERATOR_VERSION, + note: "Observed beneath a quiet green light.", + createdAt: "2026-07-17T10:00:00.000Z", +}; + +describe("Hasharium collection records", () => { + it("accepts the published collection shape", () => { + expect(parseCollectionEntryRecord(validRecord)).toEqual(validRecord); + }); + + it("rejects wrong types, invalid DIDs, dates, versions, and oversized notes", () => { + expect( + parseCollectionEntryRecord({ ...validRecord, $type: "other" }), + ).toBeUndefined(); + expect( + parseCollectionEntryRecord({ ...validRecord, subject: "not-a-did" }), + ).toBeUndefined(); + expect( + parseCollectionEntryRecord({ ...validRecord, createdAt: "yesterday" }), + ).toBeUndefined(); + expect( + parseCollectionEntryRecord({ ...validRecord, generatorVersion: "v2" }), + ).toBeUndefined(); + expect( + parseCollectionEntryRecord({ ...validRecord, note: "x".repeat(1_001) }), + ).toBeUndefined(); + }); + + it("extracts the exact record key from an AT URI", () => { + const uri = `at://did:plc:owner/${NSID.collectionEntry}/3mabc123`; + expect(recordKeyFromUri(uri)).toBe("3mabc123"); + expect(parseCollectionEntry(validRecord, uri, "bafy-record")).toMatchObject( + { + uri, + cid: "bafy-record", + rkey: "3mabc123", + }, + ); + expect(recordKeyFromUri("https://example.com/record")).toBeUndefined(); + }); + + it("enforces both grapheme and byte limits for public notes", () => { + expect(countGraphemes("a👨‍👩‍👧‍👦b")).toBe(3); + expect(validateCollectionNote("a".repeat(280))).toBeUndefined(); + expect(validateCollectionNote("a".repeat(281))).toContain("280"); + expect(validateCollectionNote("🪨".repeat(260))).toContain("1,000"); + }); +}); diff --git a/src/lib/collection.ts b/src/lib/collection.ts new file mode 100644 index 0000000..1ad5d55 --- /dev/null +++ b/src/lib/collection.ts @@ -0,0 +1,168 @@ +import type { Agent } from "@atproto/api"; +import { + GENERATOR_VERSION, + NSID, + type CollectionEntryRecord, +} from "./protocol"; +import { isDid } from "./shape"; + +const MAX_RECORD_PAGES = 10; + +export interface CollectionEntry { + uri: string; + cid: string; + rkey: string; + record: CollectionEntryRecord; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isDateTime(value: unknown): value is string { + return ( + typeof value === "string" && + value.length <= 64 && + Number.isFinite(Date.parse(value)) + ); +} + +export function countGraphemes(value: string): number { + if (typeof Intl.Segmenter === "function") { + return Array.from(new Intl.Segmenter().segment(value)).length; + } + return Array.from(value).length; +} + +export function validateCollectionNote(note: string): string | undefined { + if (new TextEncoder().encode(note).byteLength > 1_000) { + return "Field notes must be at most 1,000 UTF-8 bytes."; + } + if (countGraphemes(note) > 280) { + return "Field notes must be at most 280 characters."; + } +} + +export function parseCollectionEntryRecord( + value: unknown, +): CollectionEntryRecord | undefined { + if ( + !isRecord(value) || + value.$type !== NSID.collectionEntry || + typeof value.subject !== "string" || + !isDid(value.subject) || + !isDateTime(value.createdAt) || + (value.generatorVersion !== undefined && + value.generatorVersion !== GENERATOR_VERSION) || + (value.note !== undefined && + (typeof value.note !== "string" || + validateCollectionNote(value.note) !== undefined)) + ) { + return undefined; + } + + return { + $type: NSID.collectionEntry, + subject: value.subject, + createdAt: value.createdAt, + ...(value.generatorVersion === GENERATOR_VERSION + ? { generatorVersion: GENERATOR_VERSION } + : {}), + ...(typeof value.note === "string" ? { note: value.note } : {}), + }; +} + +export function recordKeyFromUri(uri: string): string | undefined { + const prefix = `/${NSID.collectionEntry}/`; + if (!uri.startsWith("at://") || !uri.includes(prefix)) return undefined; + const rkey = uri.slice(uri.indexOf(prefix) + prefix.length); + return /^[A-Za-z0-9._~:-]{1,512}$/.test(rkey) ? rkey : undefined; +} + +export function parseCollectionEntry( + value: unknown, + uri: unknown, + cid: unknown, +): CollectionEntry | undefined { + const record = parseCollectionEntryRecord(value); + if (typeof uri !== "string" || typeof cid !== "string" || !record) { + return undefined; + } + const rkey = recordKeyFromUri(uri); + return rkey ? { uri, cid, rkey, record } : undefined; +} + +export async function listCollectionEntries( + agent: Agent, +): Promise { + const entries: CollectionEntry[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + + for (let page = 0; page < MAX_RECORD_PAGES; page += 1) { + const response = await agent.com.atproto.repo.listRecords({ + repo: agent.assertDid, + collection: NSID.collectionEntry, + limit: 100, + cursor, + }); + for (const item of response.data.records) { + const entry = parseCollectionEntry(item.value, item.uri, item.cid); + if (entry) entries.push(entry); + } + + cursor = response.data.cursor; + if (!cursor) break; + if (seenCursors.has(cursor)) { + throw new Error("The PDS repeated a collection cursor."); + } + seenCursors.add(cursor); + } + + return entries.sort( + (left, right) => + Date.parse(right.record.createdAt) - Date.parse(left.record.createdAt), + ); +} + +export async function createCollectionEntry( + agent: Agent, + subject: string, + note = "", +): Promise { + if (!isDid(subject)) throw new Error("The specimen subject is not a DID."); + const normalizedNote = note.trim(); + const noteError = validateCollectionNote(normalizedNote); + if (noteError) throw new Error(noteError); + + const record: CollectionEntryRecord = { + $type: NSID.collectionEntry, + subject, + generatorVersion: GENERATOR_VERSION, + createdAt: new Date().toISOString(), + ...(normalizedNote ? { note: normalizedNote } : {}), + }; + const response = await agent.com.atproto.repo.createRecord({ + repo: agent.assertDid, + collection: NSID.collectionEntry, + record: { ...record }, + }); + const entry = parseCollectionEntry( + record, + response.data.uri, + response.data.cid, + ); + if (!entry) throw new Error("The PDS returned an invalid collection record."); + return entry; +} + +export async function deleteCollectionEntry( + agent: Agent, + entry: CollectionEntry, +): Promise { + await agent.com.atproto.repo.deleteRecord({ + repo: agent.assertDid, + collection: NSID.collectionEntry, + rkey: entry.rkey, + }); +} diff --git a/src/lib/components/Masthead.svelte b/src/lib/components/Masthead.svelte new file mode 100644 index 0000000..50302bc --- /dev/null +++ b/src/lib/components/Masthead.svelte @@ -0,0 +1,35 @@ + + +
+ + + Hasharium + croft.click / field registry + + +
diff --git a/src/lib/components/SiteFooter.svelte b/src/lib/components/SiteFooter.svelte new file mode 100644 index 0000000..2ddcf97 --- /dev/null +++ b/src/lib/components/SiteFooter.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/identity.test.ts b/src/lib/identity.test.ts index 59e49fe..3f99335 100644 --- a/src/lib/identity.test.ts +++ b/src/lib/identity.test.ts @@ -4,6 +4,7 @@ import { isHandle, normalizeHandle, resolveIdentity, + resolveIdentityProfile, } from "./identity"; describe("handle validation", () => { @@ -72,4 +73,52 @@ describe("identity resolution", () => { "could not be resolved right now", ); }); + + it("loads bounded public identity details for the profile space", async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + did: "did:plc:abc123", + handle: "alice.example", + pds: "https://pds.example", + }), + { status: 200 }, + ), + ); + + await expect( + resolveIdentityProfile("did:plc:abc123", fetcher), + ).resolves.toEqual({ + did: "did:plc:abc123", + handle: "alice.example", + pds: "https://pds.example", + }); + expect( + new URL(String(fetcher.mock.calls[0][0])).searchParams.get("identifier"), + ).toBe("did:plc:abc123"); + }); + + it("rejects identity-profile DID mismatches and unsafe PDS metadata", async () => { + const mismatch = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ did: "did:plc:other" }), { status: 200 }), + ); + await expect( + resolveIdentityProfile("did:plc:abc123", mismatch), + ).rejects.toThrow("different DID"); + + const unsafePds = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + did: "did:plc:abc123", + pds: "http://127.0.0.1:3000", + }), + { status: 200 }, + ), + ); + await expect( + resolveIdentityProfile("did:plc:abc123", unsafePds), + ).resolves.toEqual({ did: "did:plc:abc123" }); + }); }); diff --git a/src/lib/identity.ts b/src/lib/identity.ts index 20e6c81..f10e8a1 100644 --- a/src/lib/identity.ts +++ b/src/lib/identity.ts @@ -10,6 +10,10 @@ export interface ResolvedIdentity { handle?: string; } +export interface IdentityProfile extends ResolvedIdentity { + pds?: string; +} + export class IdentityResolutionError extends Error { constructor(message: string) { super(message); @@ -41,21 +45,30 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -export async function resolveIdentity( - value: string, - fetcher: typeof fetch = fetch, -): Promise { - const identifier = value.trim(); - if (isDid(identifier)) return { did: identifier }; - if (!isHandle(identifier)) { - throw new IdentityResolutionError( - `Enter a complete DID or handle, such as ${PLACEHOLDER_DID}.`, - ); +function publicHttpsOrigin(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + try { + const url = new URL(value); + return url.protocol === "https:" && + !url.username && + !url.password && + !url.port && + url.pathname === "/" && + !url.search && + !url.hash + ? url.origin + : undefined; + } catch { + return undefined; } +} - const handle = normalizeHandle(identifier); +async function fetchMiniDoc( + identifier: string, + fetcher: typeof fetch, +): Promise> { const url = new URL(RESOLVER_ENDPOINT); - url.searchParams.set("identifier", handle); + url.searchParams.set("identifier", identifier); let response: Response; try { @@ -66,15 +79,15 @@ export async function resolveIdentity( }); } catch { throw new IdentityResolutionError( - "That handle could not be resolved right now. Check it and try again.", + "That identity could not be resolved right now. Check it and try again.", ); } if (!response.ok) { throw new IdentityResolutionError( response.status === 404 - ? "No decentralised identity was found for that handle." - : "That handle could not be resolved right now. Check it and try again.", + ? "No decentralised identity was found for that identifier." + : "That identity could not be resolved right now. Check it and try again.", ); } @@ -110,6 +123,58 @@ export async function resolveIdentity( "The identity resolver returned an invalid response.", ); } + return payload; +} + +export async function resolveIdentity( + value: string, + fetcher: typeof fetch = fetch, +): Promise { + const identifier = value.trim(); + if (isDid(identifier)) return { did: identifier }; + if (!isHandle(identifier)) { + throw new IdentityResolutionError( + `Enter a complete DID or handle, such as ${PLACEHOLDER_DID}.`, + ); + } + + const handle = normalizeHandle(identifier); + const payload = await fetchMiniDoc(handle, fetcher); + + return { did: payload.did as string, handle }; +} + +export async function resolveIdentityProfile( + value: string, + fetcher: typeof fetch = fetch, +): Promise { + const identifier = value.trim(); + if (!isDid(identifier) && !isHandle(identifier)) { + throw new IdentityResolutionError( + `Enter a complete DID or handle, such as ${PLACEHOLDER_DID}.`, + ); + } + + const normalized = isDid(identifier) + ? identifier + : normalizeHandle(identifier); + const payload = await fetchMiniDoc(normalized, fetcher); + if (isDid(identifier) && payload.did !== identifier) { + throw new IdentityResolutionError( + "The identity resolver returned a different DID.", + ); + } - return { did: payload.did, handle }; + const handle = + typeof payload.handle === "string" && isHandle(payload.handle) + ? normalizeHandle(payload.handle) + : isHandle(identifier) + ? normalizeHandle(identifier) + : undefined; + const pds = publicHttpsOrigin(payload.pds); + return { + did: payload.did as string, + ...(handle ? { handle } : {}), + ...(pds ? { pds } : {}), + }; } diff --git a/src/lib/oauth-config.test.ts b/src/lib/oauth-config.test.ts new file mode 100644 index 0000000..0d598f2 --- /dev/null +++ b/src/lib/oauth-config.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + buildLoopbackClientId, + OAUTH_CLIENT_ID, + OAUTH_REDIRECT_URI, + OAUTH_REPOSITORY_SCOPES, + OAUTH_SCOPE, +} from "./oauth-config"; +import { NSID } from "./protocol"; + +describe("Hasharium OAuth configuration", () => { + it("publishes exact production metadata for every Hasharium record type", () => { + const metadata = JSON.parse( + readFileSync("static/oauth-client-metadata.json", "utf8"), + ); + expect(metadata.client_id).toBe(OAUTH_CLIENT_ID); + expect(metadata.redirect_uris).toEqual([OAUTH_REDIRECT_URI]); + expect(metadata.scope).toBe(OAUTH_SCOPE); + expect(OAUTH_REPOSITORY_SCOPES).toEqual( + Object.values(NSID).map((nsid) => `repo:${nsid}`), + ); + expect(new Set(metadata.scope.split(" "))).toEqual( + new Set([ + "atproto", + ...Object.values(NSID).map((nsid) => `repo:${nsid}`), + ]), + ); + expect(metadata.token_endpoint_auth_method).toBe("none"); + expect(metadata.dpop_bound_access_tokens).toBe(true); + expect(metadata.application_type).toBe("web"); + }); + + it("uses the OAuth loopback client convention during development", () => { + const clientId = new URL(buildLoopbackClientId("5173")); + expect(clientId.origin).toBe("http://localhost"); + expect(clientId.searchParams.get("redirect_uri")).toBe( + "http://127.0.0.1:5173/profile", + ); + expect(clientId.searchParams.get("scope")).toBe(OAUTH_SCOPE); + }); +}); diff --git a/src/lib/oauth-config.ts b/src/lib/oauth-config.ts new file mode 100644 index 0000000..0d26288 --- /dev/null +++ b/src/lib/oauth-config.ts @@ -0,0 +1,17 @@ +import { HASHARIUM_HOST, NSID } from "./protocol"; + +export const OAUTH_REPOSITORY_SCOPES = Object.values(NSID).map( + (nsid) => `repo:${nsid}`, +); +export const OAUTH_SCOPE = ["atproto", ...OAUTH_REPOSITORY_SCOPES].join(" "); +export const OAUTH_CLIENT_ID = `https://${HASHARIUM_HOST}/oauth-client-metadata.json`; +export const OAUTH_REDIRECT_URI = `https://${HASHARIUM_HOST}/profile`; +export const OAUTH_HANDLE_RESOLVER = "https://slingshot.microcosm.blue"; + +export function buildLoopbackClientId(port: string): string { + const redirectUri = `http://127.0.0.1:${port}/profile`; + return `http://localhost?${new URLSearchParams([ + ["redirect_uri", redirectUri], + ["scope", OAUTH_SCOPE], + ])}`; +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts new file mode 100644 index 0000000..67cb1a1 --- /dev/null +++ b/src/lib/oauth.ts @@ -0,0 +1,135 @@ +import { browser, dev } from "$app/environment"; +import type { Agent } from "@atproto/api"; +import type { + BrowserOAuthClient, + OAuthSession, +} from "@atproto/oauth-client-browser"; +import { get, writable } from "svelte/store"; +import { isHandle } from "./identity"; +import { + buildLoopbackClientId, + OAUTH_CLIENT_ID, + OAUTH_HANDLE_RESOLVER, + OAUTH_SCOPE, +} from "./oauth-config"; +import { isDid } from "./shape"; + +export type AuthState = + | { status: "idle" | "loading" | "signed-out" | "authorizing" } + | { status: "signed-in"; did: string; agent: Agent; session: OAuthSession } + | { status: "error"; message: string }; + +export const authState = writable({ status: "idle" }); + +let clientPromise: Promise | undefined; +let initialization: Promise | undefined; + +function currentClientId(): string { + if (!dev) return OAUTH_CLIENT_ID; + return buildLoopbackClientId(window.location.port || "5173"); +} + +async function getClient(): Promise { + if (!browser) throw new Error("OAuth is only available in a browser."); + clientPromise ??= import("@atproto/oauth-client-browser").then( + ({ BrowserOAuthClient }) => + BrowserOAuthClient.load({ + clientId: currentClientId(), + handleResolver: OAUTH_HANDLE_RESOLVER, + onDelete: (did) => { + const current = get(authState); + if (current.status === "signed-in" && current.did === did) { + authState.set({ status: "signed-out" }); + } + }, + }), + ); + return clientPromise; +} + +function messageFrom(reason: unknown, fallback: string): string { + return reason instanceof Error && reason.message ? reason.message : fallback; +} + +export async function initializeOAuth(): Promise { + if (!browser) return { status: "idle" }; + initialization ??= (async () => { + authState.set({ status: "loading" }); + try { + const client = await getClient(); + const result = await client.init(); + if (!result) { + const state = { status: "signed-out" } as const; + authState.set(state); + return state; + } + + const { Agent } = await import("@atproto/api"); + const state = { + status: "signed-in", + did: result.session.sub, + agent: new Agent(result.session), + session: result.session, + } as const; + authState.set(state); + return state; + } catch (reason) { + const state = { + status: "error", + message: messageFrom( + reason, + "The OAuth session could not be restored.", + ), + } as const; + authState.set(state); + return state; + } + })(); + return initialization; +} + +export async function signInWithOAuth(identifier: string): Promise { + const normalized = identifier.trim(); + if (!isDid(normalized) && !isHandle(normalized)) { + throw new Error("Enter a complete DID or AT Protocol handle."); + } + + authState.set({ status: "authorizing" }); + try { + const client = await getClient(); + await client.signIn(normalized, { + scope: OAUTH_SCOPE, + state: "hasharium-profile", + }); + throw new Error("The authorization redirect did not occur."); + } catch (reason) { + const message = messageFrom( + reason, + "The authorization request could not be started.", + ); + authState.set({ status: "error", message }); + throw new Error(message, { cause: reason }); + } +} + +export async function signOutOfOAuth(): Promise { + const current = get(authState); + if (current.status !== "signed-in") { + authState.set({ status: "signed-out" }); + return; + } + + try { + const client = await getClient(); + await client.revoke(current.did); + } finally { + authState.set({ status: "signed-out" }); + } +} + +export function dismissOAuthError(): void { + if (get(authState).status === "error") { + initialization = undefined; + authState.set({ status: "signed-out" }); + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 0270680..a0d1139 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,6 +1,15 @@ +Skip to content {@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1dc7179..2afd493 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,9 +1,19 @@ + + + About, privacy and terms — Hasharium + + + +
+ +
+
+

Field notes / operating terms

+

About
Hasharium.

+

+ Hasharium is an independent, open-source study of deterministic forms derived from decentralised identifiers. Nothing is minted, made scarce, or centrally assigned. +

+
+ +
+
+ 01 / METHOD +

What the site calculates

+

+ Direct DIDs are hashed locally with SHA-256. Handles are sent to Microcosm Slingshot for resolution to a canonical DID before hashing. Specimen geometry is reproducible for the same DID and generator version. +

+
+
+ 02 / OAUTH +

How sign-in works

+

+ Hasharium uses the official AT Protocol browser OAuth client with PKCE and DPoP. It never asks for an app password. OAuth session material is retained by that client in this browser’s IndexedDB and is removed or revoked when you sign out. +

+
+
+ 03 / PERMISSION +

What permission is requested

+

+ The application asks for granular repository access to {NSID.collectionEntry}, {NSID.intersection}, and {NSID.exhibition}. It does not grant access to record types outside Hasharium, private account data, blobs, email, or account management. +

+
+
+ 04 / PUBLIC DATA +

What a profile publishes

+

+ A collected specimen is a public record in your PDS containing its subject DID, creation time, generator version, and an optional public field note. Repository records are public by design. Do not put private information in notes. +

+
+
+ 05 / LOCAL DATA +

What stays on the device

+

+ Signed-out study-tray entries are stored in localStorage. Hasharium has no analytics and does not transmit the local tray. Clearing site data removes local tray and OAuth storage from this browser but does not delete confirmed PDS records. +

+
+
+ 06 / TERMS +

Use and availability

+

+ The service is provided without warranty under the GNU AGPL v3. Network identity services and PDSes can be unavailable or return incomplete data. Generated names and traits are playful morphology, not claims about a person’s character, health, value, or reputation. +

+
+
+ + +
+ +
+ + diff --git a/src/routes/profile/+page.svelte b/src/routes/profile/+page.svelte new file mode 100644 index 0000000..266cf3b --- /dev/null +++ b/src/routes/profile/+page.svelte @@ -0,0 +1,803 @@ + + + + Your profile — Hasharium + + + +
+ +
+
+

Curator profile / PDS cabinet

+

Your field
record.

+

+ Sign in with the identity you already own. Hasharium requests access only to its three namespaced record types; your OAuth session stays in this browser. +

+
+ + {#if $authState.status === 'idle' || $authState.status === 'loading'} +
+ AUTH / 00 +

Checking the specimen ledger…

+

Restoring a local OAuth session if one exists.

+
+ {:else if $authState.status === 'signed-out' || $authState.status === 'authorizing' || $authState.status === 'error'} + + {:else if $authState.status === 'signed-in'} +
+
+ {#if profileSpecimen} + + {/if} +
+
+
+ CURATOR / SELF + SESSION ACTIVE +
+

{identity?.handle ? `@${identity.handle}` : 'AT Protocol identity'}

+

{profileSpecimen?.name ?? 'Loading specimen…'}

+ {identity?.did ?? $authState.did} +
+
Repository
{identity?.pds ? new URL(identity.pds).hostname : 'Resolved by DID'}
+
Collection
{cabinet.length} {cabinet.length === 1 ? 'specimen' : 'specimens'}
+
Storage
Your PDS
+
+ +
+
+ +
+
+

Add observation

+

Collect a specimen

+

Resolve an identity, derive its form locally, then publish one collection record to your repository.

+
+
+ + + + +
+ Notes are public repository data · maximum 280 characters / 1,000 bytes. + +
+
+
+ +
+
+
+

Repository records

+

Your cabinet

+
+

+ Read from {NSID.collectionEntry}. Removal requires a second confirmation and succeeds only after your PDS confirms it. +

+
+ + {#if profileLoading} +
Reading collection records from your PDS…
+ {:else if cabinet.length} +
+ {#each cabinet as item} +
+
+
+ {item.specimen.catalogueNumber} +

{item.specimen.name}

+ {item.entry.record.subject} + {#if item.entry.record.note}

{item.entry.record.note}

{/if} +
+ + +
+
+
+ {/each} +
+ {:else} +
+ No PDS specimens yet. + Use the collection form above to make the first confirmed record. +
+ {/if} +
+ {/if} + + {#if error || $authState.status === 'error'} + + {:else if message} +

{message}

+ {/if} +
+ +
+ + diff --git a/src/routes/styles.css b/src/routes/styles.css index cc002a0..e1ce803 100644 --- a/src/routes/styles.css +++ b/src/routes/styles.css @@ -1,15 +1,48 @@ :root { - font-family: "Avenir Next", Avenir, "Segoe UI", sans-serif; - color: #1b2925; - background: #e9e5d9; + color-scheme: light; + font-family: var(--font-sans); + color: var(--ink); + background: var(--paper); font-synthesis: none; - --paper: #e9e5d9; - --paper-light: #f2eee4; - --ink: #1b2925; - --muted: #67706a; - --line: rgb(27 41 37 / 0.17); - --red: #c95838; - --green: #223b33; + --paper: oklch(0.92 0.018 91); + --paper-light: oklch(0.96 0.014 91); + --ink: oklch(0.27 0.025 162); + --muted: oklch(0.49 0.018 154); + --line: oklch(0.27 0.025 162 / 0.17); + --line-strong: oklch(0.27 0.025 162 / 0.48); + --red: oklch(0.59 0.145 40); + --red-dark: oklch(0.42 0.11 38); + --green: oklch(0.31 0.04 162); + --surface-raised: oklch(0.875 0.019 91); + --surface-sunken: oklch(0.945 0.014 91); + --plate: oklch(0.87 0.016 89); + --font-sans: + "Inter Variable", Inter, "Avenir Next", Avenir, "Segoe UI", sans-serif; + --font-mono: + "JetBrains Mono Variable", "JetBrains Mono", "SFMono-Regular", Consolas, + monospace; + --font-display: "Iowan Old Style", Baskerville, "Times New Roman", serif; + --space-2xs: 2px; + --space-xs: 4px; + --space-sm: 8px; + --space-3: 12px; + --space-md: 16px; + --space-6: 24px; + --space-lg: 32px; + --space-12: 48px; + --space-xl: 64px; + --space-2xl: 96px; + --control-size: 44px; + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 12px; + --radius-full: 999px; + --shell-wide: 72rem; + --shell-narrow: 72rem; + --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --duration-fast: 200ms; + --duration-normal: 300ms; } * { @@ -24,6 +57,9 @@ html { body { margin: 0; min-width: 320px; + color: var(--ink); + font-family: var(--font-sans); + line-height: 1.6; background: linear-gradient(rgb(31 45 40 / 0.026) 1px, transparent 1px), linear-gradient(90deg, rgb(31 45 40 / 0.018) 1px, transparent 1px), @@ -32,7 +68,8 @@ body { } button, -input { +input, +textarea { font: inherit; } @@ -45,6 +82,15 @@ button { color: inherit; } +button:not(:disabled) { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.65; +} + a { color: inherit; text-decoration: none; @@ -52,37 +98,76 @@ a { button:focus-visible, a:focus-visible, -input:focus-visible { - outline: 2px solid var(--red); - outline-offset: 4px; +input:focus-visible, +textarea:focus-visible { + outline: 3px solid var(--red); + outline-offset: 3px; + border-radius: var(--radius-xs); +} + +.skip-to-content { + position: fixed; + top: var(--space-md); + left: var(--space-md); + z-index: 100; + min-height: var(--control-size); + padding: 0 var(--space-md); + display: inline-flex; + align-items: center; + color: var(--paper-light); + background: var(--green); + border-radius: var(--radius-xs); + font-family: var(--font-mono); + font-size: 0.72rem; + transform: translateY(calc(-100% - var(--space-lg))); + transition: transform var(--duration-fast) var(--ease-out-quart); +} + +.skip-to-content:focus { + transform: translateY(0); } .site-shell { - overflow: hidden; + min-height: 100vh; + overflow-x: clip; } .masthead { - position: relative; - z-index: 5; + position: sticky; + top: 0; + z-index: 10; display: flex; align-items: center; justify-content: space-between; - width: min(100% - 64px, 1440px); - height: 104px; + width: min(100% - 64px, var(--shell-wide)); + min-height: 72px; margin: 0 auto; border-bottom: 1px solid var(--line); + background: color-mix(in oklch, var(--paper) 90%, transparent); + backdrop-filter: blur(14px); } .wordmark { display: inline-flex; gap: 12px; align-items: center; - font-family: "Newsreader", serif; + font-family: var(--font-display); font-size: 25px; font-weight: 500; letter-spacing: -0.025em; } +.wordmark small { + padding-left: var(--space-3); + color: var(--muted); + border-left: 1px solid var(--line); + font-family: var(--font-mono); + font-size: 0.6rem; + font-weight: 400; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .wordmark svg { width: 32px; height: 32px; @@ -101,35 +186,65 @@ input:focus-visible { .masthead nav { display: flex; - gap: 34px; + gap: var(--space-6); align-items: center; - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.075em; text-transform: uppercase; } .masthead nav a { - padding: 12px 2px; + min-height: var(--control-size); + padding: 0 var(--space-sm); + display: inline-flex; + align-items: center; + border-radius: var(--radius-xs); + transition: + color var(--duration-fast) var(--ease-out-quart), + background var(--duration-fast) var(--ease-out-quart); } -.masthead nav a:hover { +.masthead nav a:hover, +.masthead nav a.active { color: var(--red); + background: var(--surface-raised); +} + +.profile-link { + gap: var(--space-sm); +} + +.profile-link > span { + width: 6px; + height: 6px; + background: var(--line-strong); + border-radius: 50%; +} + +.profile-link > span.online { + background: var(--red); } .tray-button { display: inline-flex; gap: 10px; align-items: center; - padding: 11px 12px 11px 16px; + min-height: var(--control-size); + padding: 0 var(--space-sm) 0 var(--space-md); cursor: pointer; background: transparent; border: 1px solid var(--ink); - border-radius: 999px; + border-radius: var(--radius-full); letter-spacing: inherit; text-transform: inherit; } +.tray-button:hover { + color: var(--paper-light); + background: var(--green); +} + .tray-button span { display: grid; width: 19px; @@ -144,12 +259,12 @@ input:focus-visible { .hero { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(520px, 1.1fr); - gap: clamp(40px, 7vw, 124px); + gap: clamp(var(--space-lg), 7vw, var(--space-2xl)); align-items: center; - width: min(100% - 64px, 1360px); - min-height: 780px; + width: min(100% - 64px, var(--shell-wide)); + min-height: 720px; margin: 0 auto; - padding: 76px 0 100px; + padding: var(--space-xl) 0 var(--space-2xl); } .hero-copy { @@ -161,8 +276,8 @@ input:focus-visible { gap: 13px; align-items: center; margin: 0 0 27px; - color: #59645e; - font-family: "DM Mono", monospace; + color: var(--muted); + font-family: var(--font-mono); font-size: 10px; font-weight: 500; letter-spacing: 0.16em; @@ -180,7 +295,7 @@ input:focus-visible { .method h2, .study-tray h2 { margin: 0; - font-family: "Newsreader", serif; + font-family: var(--font-display); font-weight: 300; letter-spacing: -0.055em; } @@ -200,8 +315,8 @@ input:focus-visible { .introduction { max-width: 525px; margin: 42px 0 45px; - color: #56615b; - font-family: "Newsreader", serif; + color: var(--muted); + font-family: var(--font-display); font-size: 21px; line-height: 1.48; } @@ -213,7 +328,7 @@ input:focus-visible { .did-form > label { display: block; margin: 0 0 9px; - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; @@ -224,12 +339,17 @@ input:focus-visible { grid-template-columns: auto 1fr auto; gap: 13px; align-items: center; - min-height: 62px; - padding: 7px 8px 7px 18px; - background: rgb(245 241 231 / 0.65); - border: 1px solid #758078; - border-radius: 2px; - box-shadow: 0 8px 30px rgb(41 53 47 / 0.05); + min-height: 60px; + padding: var(--space-sm) var(--space-sm) var(--space-sm) var(--space-md); + background: var(--surface-sunken); + border: 1px solid var(--line-strong); + border-radius: var(--radius-xs); + transition: outline-color var(--duration-fast) var(--ease-out-quart); +} + +.input-row:focus-within { + outline: 3px solid var(--red); + outline-offset: 3px; } .input-row.invalid { @@ -240,7 +360,7 @@ input:focus-visible { padding-right: 13px; color: #727b76; border-right: 1px solid var(--line); - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.1em; } @@ -253,7 +373,7 @@ input:focus-visible { background: transparent; border: 0; outline: 0; - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 12px; } @@ -261,14 +381,14 @@ input:focus-visible { display: flex; gap: 15px; align-items: center; - min-height: 46px; - padding: 0 16px 0 20px; + min-height: var(--control-size); + padding: 0 var(--space-md); color: var(--paper-light); cursor: pointer; background: var(--ink); border: 0; - border-radius: 1px; - font-family: "DM Mono", monospace; + border-radius: var(--radius-xs); + font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; @@ -290,7 +410,7 @@ input:focus-visible { min-height: 16px; margin: 9px 0 0; color: #7b827e; - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 9px; line-height: 1.5; } @@ -310,7 +430,7 @@ input:focus-visible { justify-content: space-between; margin: 0 0 12px; color: #646e68; - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 8px; letter-spacing: 0.12em; } @@ -334,7 +454,8 @@ input:focus-visible { rgb(31 44 39 / 0.09) 50%, transparent 50.2% ), - radial-gradient(circle, rgb(255 255 255 / 0.44), transparent 66%), #dcd8ca; + radial-gradient(circle, rgb(255 255 255 / 0.44), transparent 66%), + var(--plate); border: 1px solid rgb(31 44 39 / 0.2); box-shadow: 0 28px 80px rgb(41 50 46 / 0.12); } @@ -393,7 +514,7 @@ input:focus-visible { .measure { position: absolute; color: rgb(27 41 37 / 0.45); - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 7px; } @@ -426,7 +547,7 @@ input:focus-visible { .catalogue-number { color: var(--red); - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 8px; font-weight: 500; letter-spacing: 0.12em; @@ -434,7 +555,7 @@ input:focus-visible { .specimen-label h2 { margin: 4px 0 2px; - font-family: "Newsreader", serif; + font-family: var(--font-display); font-size: 28px; font-weight: 400; } @@ -444,7 +565,7 @@ input:focus-visible { max-width: 370px; overflow: hidden; color: #6f7772; - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; @@ -467,7 +588,7 @@ input:focus-visible { background: none; border: 0; border-bottom: 1px solid var(--ink); - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 8px; letter-spacing: 0.06em; text-transform: uppercase; @@ -508,7 +629,7 @@ input:focus-visible { .traits dt { color: #777e79; - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 7px; letter-spacing: 0.1em; text-transform: uppercase; @@ -516,23 +637,34 @@ input:focus-visible { .traits dd { margin: 5px 0 0; - font-family: "Newsreader", serif; + font-family: var(--font-display); font-size: 15px; text-transform: capitalize; } +.collection-message { + margin: var(--space-md) 0 0; + padding: var(--space-3) var(--space-md); + color: var(--muted); + background: var(--surface-sunken); + border: 1px solid var(--line); + font-family: var(--font-mono); + font-size: 0.68rem; + line-height: 1.55; +} + .cabinet { - padding: 118px max(32px, calc((100vw - 1360px) / 2)) 130px; + padding: var(--space-2xl) max(32px, calc((100vw - var(--shell-wide)) / 2)); background: var(--paper-light); border-top: 1px solid var(--line); } .section-heading { display: flex; - gap: 50px; + gap: var(--space-12); justify-content: space-between; align-items: end; - margin-bottom: 64px; + margin-bottom: var(--space-xl); } .section-heading .eyebrow { @@ -548,7 +680,7 @@ input:focus-visible { max-width: 420px; margin: 0 0 4px; color: #69716c; - font-family: "Newsreader", serif; + font-family: var(--font-display); font-size: 17px; line-height: 1.5; } @@ -556,8 +688,10 @@ input:focus-visible { .specimen-grid { display: grid; grid-template-columns: repeat(3, 1fr); - border-top: 1px solid var(--line); - border-left: 1px solid var(--line); + gap: 2px; + padding: var(--space-xs); + background: var(--surface-raised); + border: 1px solid var(--line); } .cabinet-card { @@ -565,23 +699,22 @@ input:focus-visible { display: flex; flex-direction: column; align-items: stretch; - padding: 24px 28px 25px; + padding: var(--space-6) 28px; overflow: hidden; text-align: left; cursor: pointer; - background: rgb(233 229 217 / 0.25); + background: var(--surface-sunken); border: 0; - border-right: 1px solid var(--line); - border-bottom: 1px solid var(--line); - transition: background 180ms ease; + transition: background var(--duration-fast) var(--ease-out-quart); } -.cabinet-card:hover { - background: #e7e1d4; +.cabinet-card:hover, +.cabinet-card:focus-visible { + background: color-mix(in oklch, var(--red) 10%, var(--surface-sunken)); } .card-number { - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 8px; letter-spacing: 0.1em; } @@ -591,11 +724,6 @@ input:focus-visible { width: min(84%, 270px); aspect-ratio: 1; margin: 5px 0 13px; - transition: transform 300ms ease; -} - -.cabinet-card:hover .card-specimen { - transform: rotate(3deg) scale(1.025); } .card-rule { @@ -607,7 +735,7 @@ input:focus-visible { } .cabinet-card strong { - font-family: "Newsreader", serif; + font-family: var(--font-display); font-size: 22px; font-weight: 400; } @@ -615,7 +743,7 @@ input:focus-visible { .cabinet-card small { margin-top: 4px; color: #777e79; - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 8px; letter-spacing: 0.07em; } @@ -625,7 +753,7 @@ input:focus-visible { right: 26px; bottom: 26px; color: #535d57; - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 8px; letter-spacing: 0.06em; text-transform: uppercase; @@ -641,7 +769,7 @@ input:focus-visible { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(420px, 1.15fr); gap: clamp(70px, 10vw, 180px); - padding: 130px max(32px, calc((100vw - 1280px) / 2)) 120px; + padding: var(--space-2xl) max(32px, calc((100vw - var(--shell-wide)) / 2)); color: #e9e5d9; background: radial-gradient( @@ -666,35 +794,41 @@ input:focus-visible { max-width: 500px; margin: 38px 0 0; color: #b7c0ba; - font-family: "Newsreader", serif; + font-family: var(--font-display); font-size: 18px; line-height: 1.6; } .method-steps { margin: 0; - padding: 0; + padding: var(--space-xs); list-style: none; - border-top: 1px solid rgb(233 229 217 / 0.22); + background: rgb(233 229 217 / 0.08); + border: 1px solid rgb(233 229 217 / 0.18); } .method-steps li { display: grid; grid-template-columns: 52px 1fr; gap: 20px; - padding: 24px 0; - border-bottom: 1px solid rgb(233 229 217 / 0.22); + padding: var(--space-6) var(--space-md); + background: rgb(23 43 36 / 0.5); + border-bottom: 2px solid #20372f; +} + +.method-steps li:last-child { + border-bottom: 0; } .method-steps > li > span { padding-top: 4px; color: #d07754; - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 9px; } .method-steps strong { - font-family: "Newsreader", serif; + font-family: var(--font-display); font-size: 24px; font-weight: 400; } @@ -716,7 +850,7 @@ input:focus-visible { padding: 16px 0 0; color: #9ca9a2; border-top: 1px solid rgb(233 229 217 / 0.22); - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 8px; letter-spacing: 0.08em; } @@ -725,21 +859,43 @@ input:focus-visible { color: #e8e0cf; } -footer { +.site-footer { display: grid; grid-template-columns: 1fr auto 1fr; - gap: 30px; + gap: var(--space-lg); align-items: center; - padding: 42px max(32px, calc((100vw - 1360px) / 2)); + padding: var(--space-lg) max(32px, calc((100vw - var(--shell-wide)) / 2)); color: #65706a; background: #172b24; border-top: 1px solid rgb(233 229 217 / 0.13); - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 8px; letter-spacing: 0.06em; text-transform: uppercase; } +.site-footer nav { + display: flex; + gap: var(--space-md); + justify-content: flex-end; + flex-wrap: wrap; +} + +.site-footer nav a { + min-height: var(--control-size); + display: inline-flex; + align-items: center; + border-bottom: 1px solid transparent; + transition: + color var(--duration-fast) var(--ease-out-quart), + border-color var(--duration-fast) var(--ease-out-quart); +} + +.site-footer nav a:hover { + color: var(--paper-light); + border-color: currentcolor; +} + .footer-mark { color: #f0eadc; font-size: 20px; @@ -751,14 +907,10 @@ footer { stroke: #f0eadc; } -footer p { +.site-footer p { margin: 0; } -footer p:last-child { - text-align: right; -} - .dialog-backdrop { position: fixed; z-index: 20; @@ -766,8 +918,7 @@ footer p:last-child { display: grid; place-items: stretch end; background: rgb(15 26 22 / 0.62); - backdrop-filter: blur(5px); - animation: fade-in 180ms ease-out; + animation: fade-in var(--duration-fast) var(--ease-out-quart); } .study-tray { @@ -776,8 +927,8 @@ footer p:last-child { padding: 42px; overflow-y: auto; background: var(--paper-light); - box-shadow: -30px 0 90px rgb(10 20 16 / 0.2); - animation: slide-in 260ms ease-out; + border-left: 1px solid var(--line-strong); + animation: slide-in var(--duration-normal) var(--ease-out-expo); } .tray-heading { @@ -790,7 +941,7 @@ footer p:last-child { .tray-heading span { color: var(--red); - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 8px; letter-spacing: 0.12em; } @@ -801,13 +952,13 @@ footer p:last-child { } .tray-heading button { - width: 42px; - height: 42px; + width: var(--control-size); + height: var(--control-size); cursor: pointer; background: transparent; border: 1px solid var(--ink); border-radius: 50%; - font-family: "Newsreader", serif; + font-family: var(--font-display); font-size: 28px; line-height: 1; } @@ -815,21 +966,23 @@ footer p:last-child { .tray-note { margin: 22px 0 30px; color: #626d67; - font-family: "Newsreader", serif; + font-family: var(--font-display); font-size: 15px; line-height: 1.55; } .tray-note code { - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 9px; } .tray-grid { display: grid; grid-template-columns: repeat(2, 1fr); - border-top: 1px solid var(--line); - border-left: 1px solid var(--line); + gap: 2px; + padding: var(--space-xs); + background: var(--surface-raised); + border: 1px solid var(--line); } .tray-grid button { @@ -838,15 +991,19 @@ footer p:last-child { padding: 20px; text-align: left; cursor: pointer; - background: transparent; + background: var(--surface-sunken); border: 0; - border-right: 1px solid var(--line); - border-bottom: 1px solid var(--line); + transition: background var(--duration-fast) var(--ease-out-quart); +} + +.tray-grid button:hover, +.tray-grid button:focus-visible { + background: color-mix(in oklch, var(--red) 10%, var(--surface-sunken)); } .tray-grid button strong { margin-top: -3px; - font-family: "Newsreader", serif; + font-family: var(--font-display); font-size: 18px; font-weight: 400; } @@ -854,7 +1011,7 @@ footer p:last-child { .tray-grid button small { margin-top: 4px; color: #747c77; - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 7px; } @@ -868,19 +1025,19 @@ footer p:last-child { .empty-tray span { color: var(--red); - font-family: "Newsreader", serif; + font-family: var(--font-display); font-size: 50px; } .empty-tray p { margin: 8px 0 4px; - font-family: "Newsreader", serif; + font-family: var(--font-display); font-size: 22px; } .empty-tray small { color: #777f7a; - font-family: "DM Mono", monospace; + font-family: var(--font-mono); font-size: 8px; } @@ -919,17 +1076,29 @@ footer p:last-child { @media (max-width: 680px) { .masthead { width: calc(100% - 32px); - height: 78px; + min-height: 64px; } - .masthead nav > a { + .masthead nav > a:not(.profile-link) { display: none; } + .masthead nav { + gap: var(--space-xs); + } + + .profile-link { + padding-inline: var(--space-xs) !important; + } + .wordmark { font-size: 22px; } + .wordmark small { + display: none; + } + .hero { width: calc(100% - 32px); min-height: 0; @@ -1016,13 +1185,13 @@ footer p:last-child { flex-direction: column; } - footer { + .site-footer { grid-template-columns: 1fr; padding: 38px 20px; } - footer p:last-child { - text-align: left; + .site-footer nav { + justify-content: flex-start; } .study-tray { diff --git a/static/font-licenses.txt b/static/font-licenses.txt new file mode 100644 index 0000000..a2c7deb --- /dev/null +++ b/static/font-licenses.txt @@ -0,0 +1,98 @@ +Hasharium font licences +======================== + +Inter +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +JetBrains Mono +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +Both font families are distributed under the SIL Open Font License, +Version 1.1. The shared licence text follows. + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/static/oauth-client-metadata.json b/static/oauth-client-metadata.json new file mode 100644 index 0000000..5eb38dc --- /dev/null +++ b/static/oauth-client-metadata.json @@ -0,0 +1,15 @@ +{ + "client_id": "https://hasharium.croft.click/oauth-client-metadata.json", + "client_name": "Hasharium", + "client_uri": "https://hasharium.croft.click", + "logo_uri": "https://hasharium.croft.click/favicon.svg", + "tos_uri": "https://hasharium.croft.click/about", + "policy_uri": "https://hasharium.croft.click/about", + "redirect_uris": ["https://hasharium.croft.click/profile"], + "scope": "atproto repo:click.croft.hasharium.collection.entry repo:click.croft.hasharium.exhibition repo:click.croft.hasharium.intersection", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "application_type": "web", + "dpop_bound_access_tokens": true +}