From 08200672eae617947e9955b3870848f227e01310 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Mon, 23 Mar 2026 08:22:08 +0100 Subject: [PATCH 01/14] refactor: replace res.json delete interception with syndicator delete() hook Remove the fragile middleware in contentNegotiationRoutes that wrapped res.json to detect successful Micropub delete responses. Replace it with clean delete() and update() lifecycle methods on ActivityPubEndpoint that are called directly by post-content.js via callSyndicatorHook. Also adds broadcastPostUpdate() to send Update activities for edited posts, mirroring the broadcastDelete() batch-delivery pattern. --- index.js | 151 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 119 insertions(+), 32 deletions(-) diff --git a/index.js b/index.js index a6494dc..8e611c6 100644 --- a/index.js +++ b/index.js @@ -387,38 +387,6 @@ export default class ActivityPubEndpoint { const router = express.Router(); // eslint-disable-line new-cap const self = this; - // Intercept Micropub delete actions to broadcast Delete to fediverse. - // Wraps res.json to detect successful delete responses, then fires - // broadcastDelete asynchronously so remote servers remove the post. - router.use((req, res, next) => { - if (req.method !== "POST") return next(); - if (!req.path.endsWith("/micropub")) return next(); - - const action = req.query?.action || req.body?.action; - if (action !== "delete") return next(); - - const postUrl = req.query?.url || req.body?.url; - if (!postUrl) return next(); - - const originalJson = res.json.bind(res); - res.json = function (body) { - // Fire broadcastDelete after successful delete (status 200) - if (res.statusCode === 200 && body?.success === "delete") { - console.info( - `[ActivityPub] Micropub delete detected for ${postUrl}, broadcasting Delete to followers`, - ); - self.broadcastDelete(postUrl).catch((error) => { - console.warn( - `[ActivityPub] broadcastDelete after Micropub delete failed: ${error.message}`, - ); - }); - } - return originalJson(body); - }; - - return next(); - }); - // Let Fedify handle NodeInfo data (/nodeinfo/2.1) // Only pass GET/HEAD requests — POST/PUT/DELETE must not go through // Fedify here, because fromExpressRequest() consumes the body stream, @@ -1181,6 +1149,125 @@ export default class ActivityPubEndpoint { } } + /** + * Called by post-content.js when a Micropub delete succeeds. + * Broadcasts an ActivityPub Delete activity to all followers. + * @param {string} url - Full URL of the deleted post + */ + async delete(url) { + await this.broadcastDelete(url).catch((err) => + console.warn(`[ActivityPub] broadcastDelete failed for ${url}: ${err.message}`) + ); + } + + /** + * Called by post-content.js when a Micropub update succeeds. + * Broadcasts an ActivityPub Update activity for the post to all followers. + * @param {object} properties - JF2 post properties (must include url) + */ + async update(properties) { + await this.broadcastPostUpdate(properties).catch((err) => + console.warn(`[ActivityPub] broadcastPostUpdate failed for ${properties?.url}: ${err.message}`) + ); + } + + /** + * Send an Update activity to all followers for a modified post. + * Mirrors broadcastDelete() pattern: batch delivery with shared inbox dedup. + * @param {object} properties - JF2 post properties + */ + async broadcastPostUpdate(properties) { + if (!this._federation) return; + + try { + const actorUrl = this._getActorUrl(); + const activity = jf2ToAS2Activity( + properties, + actorUrl, + this._publicationUrl, + { visibility: this.options.defaultVisibility }, + ); + + if (!activity) { + console.warn(`[ActivityPub] broadcastPostUpdate: could not convert post to AS2 for ${properties?.url}`); + return; + } + + const handle = this.options.actor.handle; + const ctx = this._federation.createContext( + new URL(this._publicationUrl), + { handle, publicationUrl: this._publicationUrl }, + ); + + const followers = await this._collections.ap_followers + .find({}) + .project({ actorUrl: 1, inbox: 1, sharedInbox: 1 }) + .toArray(); + + const inboxMap = new Map(); + for (const f of followers) { + const key = f.sharedInbox || f.inbox; + if (key && !inboxMap.has(key)) { + inboxMap.set(key, f); + } + } + + const uniqueRecipients = [...inboxMap.values()]; + const BATCH_SIZE = 25; + const BATCH_DELAY_MS = 5000; + let delivered = 0; + let failed = 0; + + console.info( + `[ActivityPub] Broadcasting Update for ${properties.url} to ${uniqueRecipients.length} unique inboxes`, + ); + + for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) { + const batch = uniqueRecipients.slice(i, i + BATCH_SIZE); + const recipients = batch.map((f) => ({ + id: new URL(f.actorUrl), + inboxId: new URL(f.inbox || f.sharedInbox), + endpoints: f.sharedInbox + ? { sharedInbox: new URL(f.sharedInbox) } + : undefined, + })); + + try { + await ctx.sendActivity( + { identifier: handle }, + recipients, + activity, + { preferSharedInbox: true }, + ); + delivered += batch.length; + } catch (error) { + failed += batch.length; + console.warn( + `[ActivityPub] Update batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`, + ); + } + + if (i + BATCH_SIZE < uniqueRecipients.length) { + await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS)); + } + } + + console.info( + `[ActivityPub] Update broadcast complete for ${properties.url}: ${delivered} delivered, ${failed} failed`, + ); + + await logActivity(this._collections.ap_activities, { + direction: "outbound", + type: "Update", + actorUrl: this._publicationUrl, + objectUrl: properties.url, + summary: `Sent Update for ${properties.url} to ${delivered} inboxes`, + }).catch(() => {}); + } catch (error) { + console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message); + } + } + /** * Build the full actor URL from config. * @returns {string} From ee0b0bb58a237e267bf1b5d95013780a49bf92ac Mon Sep 17 00:00:00 2001 From: Ricardo Date: Mon, 23 Mar 2026 08:25:24 +0100 Subject: [PATCH 02/14] fix: broadcast Update activity (not Create) in broadcastPostUpdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jf2ToAS2Activity() always returns a Create activity. For post edits, Fediverse servers expect an Update wrapping the updated object, not a second Create. Extract the Note/Article from the Create via getObject(), then wrap it in new Update({ actor, object: note }) — matching the existing broadcastActorUpdate() pattern exactly. --- index.js | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 8e611c6..4ca609b 100644 --- a/index.js +++ b/index.js @@ -1180,24 +1180,37 @@ export default class ActivityPubEndpoint { if (!this._federation) return; try { + const { Update } = await import("@fedify/fedify/vocab"); const actorUrl = this._getActorUrl(); - const activity = jf2ToAS2Activity( + const handle = this.options.actor.handle; + const ctx = this._federation.createContext( + new URL(this._publicationUrl), + { handle, publicationUrl: this._publicationUrl }, + ); + + // Build the Note/Article object by calling jf2ToAS2Activity() and + // extracting the wrapped object from the returned Create activity. + // For post edits, Fediverse servers expect an Update activity wrapping + // the updated object — NOT a second Create activity. + const createActivity = jf2ToAS2Activity( properties, actorUrl, this._publicationUrl, { visibility: this.options.defaultVisibility }, ); - if (!activity) { + if (!createActivity) { console.warn(`[ActivityPub] broadcastPostUpdate: could not convert post to AS2 for ${properties?.url}`); return; } - const handle = this.options.actor.handle; - const ctx = this._federation.createContext( - new URL(this._publicationUrl), - { handle, publicationUrl: this._publicationUrl }, - ); + // Extract the Note/Article object from the Create wrapper, then build + // an Update activity around it — matching broadcastActorUpdate() pattern. + const noteObject = await createActivity.getObject(); + const activity = new Update({ + actor: ctx.getActorUri(handle), + object: noteObject, + }); const followers = await this._collections.ap_followers .find({}) @@ -1702,6 +1715,7 @@ export default class ActivityPubEndpoint { }, 10_000); // Run one-time migrations (idempotent — safe to run on every startup) + console.info("[ActivityPub] Init: starting post-refollow setup"); runSeparateMentionsMigration(this._collections).then(({ skipped, updated }) => { if (!skipped) { console.log(`[ActivityPub] Migration separate-mentions: updated ${updated} timeline items`); @@ -1748,6 +1762,7 @@ export default class ActivityPubEndpoint { }); // Start async inbox queue processor (processes one item every 3s) + console.info("[ActivityPub] Init: starting inbox queue processor"); this._inboxProcessorInterval = startInboxProcessor( this._collections, () => this._federation?.createContext(new URL(this._publicationUrl), { From 3ace60a1c8e08223b9e7a9230b6eacdd800019a1 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Mon, 23 Mar 2026 08:30:10 +0100 Subject: [PATCH 03/14] fix: expose delete() and update() on ActivityPub syndicator object --- index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/index.js b/index.js index 4ca609b..0c1c01e 100644 --- a/index.js +++ b/index.js @@ -683,6 +683,9 @@ export default class ActivityPubEndpoint { return undefined; } }, + + delete: async (url) => this.delete(url), + update: async (properties) => this.update(properties), }; } From 12454749adde3c6ebebcde4fcb5cc640cd659b57 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 25 Mar 2026 07:41:20 +0100 Subject: [PATCH 04/14] fix: comprehensive security, performance, and architecture audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 27 issues fixed from multi-dimensional code review (4 Critical, 6 High, 11 Medium, 6 Low): Security (Critical): - Escape HTML in OAuth authorization page to prevent XSS (C1) - Add CSRF protection to OAuth authorize flow (C2) - Replace bypassable regex sanitizer with sanitize-html library (C3) - Enforce OAuth scopes on all Mastodon API routes (C4) Security (Medium/Low): - Fix SSRF via DNS resolution before private IP check (M1) - Add rate limiting to API, auth, and app registration endpoints (M2) - Validate redirect_uri on POST /oauth/authorize (M4) - Fix custom emoji URL injection with scheme validation + escaping (M5) - Remove data: scheme from allowed image sources (L6) - Add access token expiry (1hr) and refresh token rotation (90d) (M3) - Hash client secrets before storage (L3) Architecture: - Extract batch-broadcast.js — shared delivery logic (H1a) - Extract init-indexes.js — MongoDB index creation (H1b) - Extract syndicator.js — syndication logic (H1c) - Create federation-actions.js facade for controllers (M6) - index.js reduced from 1810 to ~1169 lines (35%) Performance: - Cache moderation data with 30s TTL + write invalidation (H6) - Increase inbox queue throughput to 10 items/sec (H5) - Make account enrichment non-blocking with fire-and-forget (H4) - Remove ephemeral getReplies/getLikes/getShares from ingest (M11) - Fix LRU caches to use true LRU eviction (L1) - Fix N+1 backfill queries with batch $in lookup (L2) UI/UX: - Split 3441-line reader.css into 15 feature-scoped files (H2) - Extract inline Alpine.js interaction component (H3) - Reduce sidebar navigation from 7 to 3 items (M7) - Add ARIA live regions for dynamic content updates (M8) - Extract shared CW/non-CW content partial (M9) - Document form handling pattern convention (M10) - Add accessible labels to functional emoji icons (L4) - Convert profile editor to Alpine.js (L5) Audit: documentation-central/audits/2026-03-24-activitypub-code-review.md Plan: documentation-central/plans/2026-03-24-activitypub-audit-fixes.md --- .gitignore | 2 + CLAUDE.md | 31 + assets/css/base.css | 144 + assets/css/card.css | 377 +++ assets/css/compose.css | 169 ++ assets/css/dark-mode.css | 94 + assets/css/explore.css | 530 ++++ assets/css/features.css | 436 +++ assets/css/federation.css | 242 ++ assets/css/interactions.css | 236 ++ assets/css/media.css | 315 +++ assets/css/messages.css | 158 ++ assets/css/moderation.css | 119 + assets/css/notifications.css | 191 ++ assets/css/profile.css | 308 ++ assets/css/responsive.css | 33 + assets/css/skeleton.css | 74 + assets/reader-interactions.js | 115 + assets/reader.css | 3459 +---------------------- index.js | 728 +---- lib/batch-broadcast.js | 98 + lib/controllers/compose.js | 12 +- lib/controllers/interactions-boost.js | 21 +- lib/controllers/interactions-like.js | 21 +- lib/federation-actions.js | 70 + lib/inbox-queue.js | 26 +- lib/init-indexes.js | 251 ++ lib/item-processing.js | 24 +- lib/lookup-cache.js | 3 + lib/mastodon/backfill-timeline.js | 13 +- lib/mastodon/entities/sanitize.js | 107 +- lib/mastodon/helpers/account-cache.js | 3 + lib/mastodon/helpers/enrich-accounts.js | 97 +- lib/mastodon/router.js | 31 + lib/mastodon/routes/accounts.js | 65 +- lib/mastodon/routes/media.js | 10 +- lib/mastodon/routes/notifications.js | 30 +- lib/mastodon/routes/oauth.js | 109 +- lib/mastodon/routes/search.js | 4 +- lib/mastodon/routes/statuses.js | 66 +- lib/mastodon/routes/timelines.js | 9 +- lib/og-unfurl.js | 87 +- lib/storage/moderation.js | 13 +- lib/syndicator.js | 239 ++ lib/timeline-store.js | 26 +- package-lock.json | 123 +- package.json | 3 +- views/activitypub-federation-mgmt.njk | 4 +- views/activitypub-moderation.njk | 2 +- views/activitypub-profile.njk | 90 +- views/activitypub-reader.njk | 3 +- views/layouts/ap-reader.njk | 2 + views/partials/ap-item-card.njk | 131 +- views/partials/ap-item-content.njk | 20 + views/partials/ap-notification-card.njk | 2 +- 55 files changed, 4845 insertions(+), 4731 deletions(-) create mode 100644 assets/css/base.css create mode 100644 assets/css/card.css create mode 100644 assets/css/compose.css create mode 100644 assets/css/dark-mode.css create mode 100644 assets/css/explore.css create mode 100644 assets/css/features.css create mode 100644 assets/css/federation.css create mode 100644 assets/css/interactions.css create mode 100644 assets/css/media.css create mode 100644 assets/css/messages.css create mode 100644 assets/css/moderation.css create mode 100644 assets/css/notifications.css create mode 100644 assets/css/profile.css create mode 100644 assets/css/responsive.css create mode 100644 assets/css/skeleton.css create mode 100644 assets/reader-interactions.js create mode 100644 lib/batch-broadcast.js create mode 100644 lib/federation-actions.js create mode 100644 lib/init-indexes.js create mode 100644 lib/syndicator.js create mode 100644 views/partials/ap-item-content.njk diff --git a/.gitignore b/.gitignore index 3c3629e..bfb974a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ node_modules +.playwright-cli/ +.playwright-mcp/ diff --git a/CLAUDE.md b/CLAUDE.md index a4347d3..272dc98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -618,6 +618,37 @@ curl -s "https://rmendes.net/nodeinfo/2.1" | jq . - `@_followback@tags.pub` does not send Follow activities back despite accepting ours - Both suggest tags.pub's outbound delivery is broken — zero inbound requests from `activitypub-bot` user-agent have been observed +## Form Handling Convention + +Two form patterns are used in this plugin. New forms should follow the appropriate pattern. + +### Pattern 1: Traditional POST (data mutation forms) + +Used for: compose, profile editor, migration alias, notification mark-read/clear. + +- Standard `
` +- CSRF via `` +- Server processes, then redirects (PRG pattern) +- Success/error feedback via Indiekit's notification banner system +- Uses Indiekit form macros (`input`, `textarea`, `button`) where available + +### Pattern 2: Alpine.js Fetch (in-page CRUD operations) + +Used for: moderation add/remove keyword/server, tab management, federation actions. + +- Alpine.js `@submit.prevent` or `@click` handlers +- CSRF via `X-CSRF-Token` header in `fetch()` call +- Inline error display with `x-show="error"` and `role="alert"` +- Optimistic UI with rollback on failure +- No page reload — DOM updates in place + +### Rules + +- Do NOT mix patterns on the same page (one pattern per form) +- All forms MUST include CSRF protection (hidden field OR header) +- Error feedback: Pattern 1 uses redirect + banner, Pattern 2 uses inline `x-show="error"` +- Success feedback: Pattern 1 uses redirect + banner, Pattern 2 uses inline DOM update or element removal + ## CSS Conventions The reader CSS (`assets/reader.css`) uses Indiekit's theme custom properties for automatic dark mode support: diff --git a/assets/css/base.css b/assets/css/base.css new file mode 100644 index 0000000..c2d598e --- /dev/null +++ b/assets/css/base.css @@ -0,0 +1,144 @@ +/** + * ActivityPub Reader Styles + * Card-based layout inspired by Phanpy/Elk + * Uses Indiekit CSS custom properties for automatic dark mode support + */ + +/* ========================================================================== + Breadcrumb Navigation + ========================================================================== */ + +.ap-breadcrumb { + display: flex; + align-items: center; + gap: var(--space-xs); + margin-bottom: var(--space-m); + font-size: var(--font-size-s); + color: var(--color-on-offset); +} + +.ap-breadcrumb a { + color: var(--color-primary-on-background); + text-decoration: none; +} + +.ap-breadcrumb a:hover { + text-decoration: underline; +} + +.ap-breadcrumb__separator { + color: var(--color-on-offset); +} + +.ap-breadcrumb__current { + color: var(--color-on-background); + font-weight: 600; +} + +/* ========================================================================== + Fediverse Lookup + ========================================================================== */ + +.ap-lookup { + display: flex; + gap: var(--space-xs); + margin-bottom: var(--space-m); +} + +.ap-lookup__input { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + background: var(--color-offset); + box-sizing: border-box; + color: var(--color-on-background); + font-family: inherit; + font-size: var(--font-size-m); + padding: var(--space-s) var(--space-m); + width: 100%; +} + +.ap-lookup__input::placeholder { + color: var(--color-on-offset); +} + +.ap-lookup__input:focus { + outline: 2px solid var(--color-primary); + outline-offset: -1px; + border-color: var(--color-primary); +} + +.ap-lookup__btn { + padding: var(--space-s) var(--space-m); + border: var(--border-width-thin) solid var(--color-primary); + border-radius: var(--border-radius-small); + background: var(--color-primary); + color: var(--color-on-primary); + font-size: var(--font-size-m); + font-family: inherit; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.ap-lookup__btn:hover { + opacity: 0.9; +} + +/* ========================================================================== + Tab Navigation + ========================================================================== */ + +.ap-tabs { + border-bottom: var(--border-width-thin) solid var(--color-outline); + display: flex; + gap: var(--space-xs); + margin-bottom: var(--space-m); + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.ap-tab { + border-bottom: var(--border-width-thick) solid transparent; + color: var(--color-on-offset); + font-size: var(--font-size-m); + padding: var(--space-s) var(--space-m); + text-decoration: none; + transition: + color 0.2s ease, + border-color 0.2s ease; + white-space: nowrap; +} + +.ap-tab:hover { + color: var(--color-on-background); +} + +.ap-tab--active { + border-bottom-color: var(--color-primary); + color: var(--color-primary-on-background); + font-weight: 600; +} + +.ap-tab__count { + background: var(--color-offset-variant); + border-radius: var(--border-radius-large); + font-size: var(--font-size-xs); + font-weight: 600; + margin-left: var(--space-xs); + padding: 1px 6px; +} + +.ap-tab--active .ap-tab__count { + background: var(--color-primary); + color: var(--color-on-primary, var(--color-neutral99)); +} + +/* ========================================================================== + Timeline Layout + ========================================================================== */ + +.ap-timeline { + display: flex; + flex-direction: column; + gap: var(--space-m); +} diff --git a/assets/css/card.css b/assets/css/card.css new file mode 100644 index 0000000..88e1f9c --- /dev/null +++ b/assets/css/card.css @@ -0,0 +1,377 @@ +/* ========================================================================== + Item Card — Base + ========================================================================== */ + +.ap-card { + background: var(--color-offset); + border: var(--border-width-thin) solid var(--color-outline); + border-left: 3px solid var(--color-outline); + border-radius: var(--border-radius-small); + overflow: hidden; + padding: var(--space-m); + box-shadow: 0 1px 2px hsl(var(--tint-neutral) 10% / 0.04); + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.ap-card:hover { + border-color: var(--color-outline-variant); + border-left-color: var(--color-outline-variant); + box-shadow: 0 2px 8px hsl(var(--tint-neutral) 10% / 0.08); +} + +/* ========================================================================== + Item Card — Post Type Differentiation + ========================================================================== */ + +/* Notes: default purple-ish accent (the most common type) */ +.ap-card--note { + border-left-color: var(--color-purple45); +} + +.ap-card--note:hover { + border-left-color: var(--color-purple45); +} + +/* Articles: green accent (long-form content stands out) */ +.ap-card--article { + border-left-color: var(--color-green50); +} + +.ap-card--article:hover { + border-left-color: var(--color-green50); +} + +/* Boosts: yellow accent (shared content) */ +.ap-card--boost { + border-left-color: var(--color-yellow50); +} + +.ap-card--boost:hover { + border-left-color: var(--color-yellow50); +} + +/* Replies: blue accent (via primary color) */ +.ap-card--reply { + border-left-color: var(--color-primary); +} + +.ap-card--reply:hover { + border-left-color: var(--color-primary); +} + +/* ========================================================================== + Boost Header + ========================================================================== */ + +.ap-card__boost { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin-bottom: var(--space-s); + padding-bottom: var(--space-xs); +} + +.ap-card__boost a { + color: var(--color-on-offset); + font-weight: 600; + text-decoration: none; +} + +.ap-card__boost a:hover { + color: var(--color-on-background); + text-decoration: underline; +} + +/* ========================================================================== + Reply Context + ========================================================================== */ + +.ap-card__reply-to { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin-bottom: var(--space-s); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-card__reply-to a { + color: var(--color-primary-on-background); + text-decoration: none; +} + +.ap-card__reply-to a:hover { + text-decoration: underline; +} + +/* ========================================================================== + Author Header + ========================================================================== */ + +.ap-card__author { + align-items: center; + display: flex; + gap: var(--space-s); + margin-bottom: var(--space-s); +} + +.ap-card__avatar-wrap { + flex-shrink: 0; + height: 44px; + position: relative; + width: 44px; +} + +.ap-card__avatar { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: 50%; + height: 44px; + object-fit: cover; + width: 44px; +} + +.ap-card__avatar-wrap > img { + position: absolute; + inset: 0; + z-index: 1; +} + +.ap-card__avatar--default { + align-items: center; + background: var(--color-offset-variant); + color: var(--color-on-offset); + display: inline-flex; + font-size: 1.1em; + font-weight: 600; + justify-content: center; +} + +.ap-card__author-info { + display: flex; + flex-direction: column; + flex: 1; + gap: 1px; + min-width: 0; +} + +.ap-card__author-name { + font-size: 0.95em; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-card__author-name a { + color: inherit; + text-decoration: none; +} + +.ap-card__author-name a:hover { + text-decoration: underline; +} + +.ap-card__bot-badge { + display: inline-block; + font-size: 0.6rem; + font-weight: 700; + line-height: 1; + padding: 0.15em 0.35em; + margin-left: 0.3em; + border: var(--border-width-thin) solid var(--color-on-offset); + border-radius: var(--border-radius-small); + color: var(--color-on-offset); + vertical-align: middle; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.ap-card__author-handle { + color: var(--color-on-offset); + font-size: var(--font-size-s); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-card__timestamp { + color: var(--color-on-offset); + flex-shrink: 0; + font-size: var(--font-size-s); +} + +.ap-card__edited { + font-size: var(--font-size-xs); + margin-left: 0.2em; +} + +.ap-card__visibility { + font-size: var(--font-size-xs); + margin-left: 0.3em; + opacity: 0.7; +} + +.ap-card__timestamp-link { + color: inherit; + text-decoration: none; + display: flex; + align-items: center; + gap: 0; +} + +.ap-card__timestamp-link:hover { + text-decoration: underline; + color: var(--color-primary-on-background); +} + +/* ========================================================================== + Post Title (Articles) + ========================================================================== */ + +.ap-card__title { + font-size: var(--font-size-l); + font-weight: 600; + line-height: var(--line-height-tight); + margin-bottom: var(--space-s); +} + +.ap-card__title a { + color: inherit; + text-decoration: none; +} + +.ap-card__title a:hover { + text-decoration: underline; +} + +/* ========================================================================== + Content + ========================================================================== */ + +.ap-card__content { + color: var(--color-on-background); + line-height: calc(4 / 3 * 1em); + margin-bottom: var(--space-s); + overflow-wrap: break-word; + word-break: break-word; +} + +.ap-card__content a { + color: var(--color-primary-on-background); +} + +.ap-card__content p { + margin-bottom: var(--space-xs); +} + +.ap-card__content p:last-child { + margin-bottom: 0; +} + +.ap-card__content blockquote { + border-left: var(--border-width-thickest) solid var(--color-outline); + margin: var(--space-s) 0; + padding-left: var(--space-m); +} + +.ap-card__content pre { + background: var(--color-offset-variant); + border-radius: var(--border-radius-small); + overflow-x: auto; + padding: var(--space-s); +} + +.ap-card__content code { + background: var(--color-offset-variant); + border-radius: var(--border-radius-small); + font-size: 0.9em; + padding: 1px 4px; +} + +.ap-card__content pre code { + background: none; + padding: 0; +} + +.ap-card__content img { + border-radius: var(--border-radius-small); + height: auto; + max-width: 100%; +} + +/* @mentions — keep inline, style as subtle links */ +.ap-card__content .h-card { + display: inline; +} + +.ap-card__content .h-card a, +.ap-card__content a.u-url.mention { + display: inline; + color: var(--color-on-offset); + text-decoration: none; + white-space: nowrap; +} + +.ap-card__content .h-card a span, +.ap-card__content a.u-url.mention span { + display: inline; +} + +.ap-card__content .h-card a:hover, +.ap-card__content a.u-url.mention:hover { + color: var(--color-primary-on-background); + text-decoration: underline; +} + +/* Hashtag mentions — keep inline, subtle styling */ +.ap-card__content a.mention.hashtag { + display: inline; + color: var(--color-on-offset); + text-decoration: none; + white-space: nowrap; +} + +.ap-card__content a.mention.hashtag span { + display: inline; +} + +.ap-card__content a.mention.hashtag:hover { + color: var(--color-primary-on-background); + text-decoration: underline; +} + +/* Mastodon's invisible/ellipsis spans for long URLs */ +.ap-card__content .invisible { + display: none; +} + +.ap-card__content .ellipsis::after { + content: "…"; +} + +/* ========================================================================== + Content Warning + ========================================================================== */ + +.ap-card__cw { + margin-bottom: var(--space-s); +} + +.ap-card__cw-toggle { + background: var(--color-offset-variant); + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-background); + cursor: pointer; + display: block; + font-size: var(--font-size-s); + padding: var(--space-s) var(--space-m); + text-align: left; + transition: background 0.2s ease; + width: 100%; +} + +.ap-card__cw-toggle:hover { + background: var(--color-offset-variant-darker); +} diff --git a/assets/css/compose.css b/assets/css/compose.css new file mode 100644 index 0000000..6fb0be9 --- /dev/null +++ b/assets/css/compose.css @@ -0,0 +1,169 @@ +/* ========================================================================== + Compose Form + ========================================================================== */ + +.ap-compose__context { + background: var(--color-offset); + border-left: var(--border-width-thickest) solid var(--color-primary); + border-radius: var(--border-radius-small); + margin-bottom: var(--space-m); + padding: var(--space-m); +} + +.ap-compose__context-label { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin-bottom: var(--space-xs); +} + +.ap-compose__context-author a { + font-weight: 600; + text-decoration: none; +} + +.ap-compose__context-text { + border: 0; + font-size: var(--font-size-s); + line-height: var(--line-height-loose); + margin: var(--space-xs) 0; + padding: 0; +} + +.ap-compose__context-link { + color: var(--color-on-offset); + font-size: var(--font-size-s); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-compose__form { + display: flex; + flex-direction: column; + gap: var(--space-m); +} + +.ap-compose__editor { + position: relative; +} + +.ap-compose__textarea { + background: var(--color-background); + border: var(--border-width-thick) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-background); + font-family: inherit; + font-size: var(--font-size-m); + line-height: var(--line-height-prose); + padding: var(--space-s); + resize: vertical; + width: 100%; +} + +.ap-compose__textarea:focus { + border-color: var(--color-primary); + outline: var(--border-width-thick) solid var(--color-primary); + outline-offset: -2px; +} + +.ap-compose__cw { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.ap-compose__cw-toggle { + cursor: pointer; + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: var(--font-size-s); + color: var(--color-on-offset); +} + +.ap-compose__cw-input { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + background: var(--color-offset); + color: var(--color-on-background); + font: inherit; + font-size: var(--font-size-s); + padding: var(--space-s); + width: 100%; +} + +.ap-compose__cw-input:focus { + border-color: var(--color-primary); + outline: none; +} + +.ap-compose__visibility { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + display: flex; + flex-wrap: wrap; + gap: var(--space-s) var(--space-m); + padding: var(--space-m); +} + +.ap-compose__visibility legend { + font-weight: 600; +} + +.ap-compose__visibility-option { + cursor: pointer; + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: var(--font-size-s); +} + +.ap-compose__syndication { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-m); +} + +.ap-compose__syndication legend { + font-weight: 600; +} + +.ap-compose__syndication-target { + cursor: pointer; + display: flex; + gap: var(--space-xs); +} + +.ap-compose__actions { + align-items: center; + display: flex; + gap: var(--space-m); +} + +.ap-compose__submit { + background: var(--color-primary); + border: 0; + border-radius: var(--border-radius-small); + color: var(--color-on-primary, var(--color-neutral99)); + cursor: pointer; + font-size: var(--font-size-m); + font-weight: 600; + padding: var(--space-s) var(--space-l); +} + +.ap-compose__submit:hover { + opacity: 0.9; +} + +.ap-compose__cancel { + color: var(--color-on-offset); + text-decoration: none; +} + +.ap-compose__cancel:hover { + color: var(--color-on-background); + text-decoration: underline; +} diff --git a/assets/css/dark-mode.css b/assets/css/dark-mode.css new file mode 100644 index 0000000..4f405b8 --- /dev/null +++ b/assets/css/dark-mode.css @@ -0,0 +1,94 @@ +/* ========================================================================== + Dark Mode Overrides + Softens saturated colors that are uncomfortable on dark backgrounds. + Uses Indiekit's existing light-variant tokens (red80, green90, yellow90) + which are designed for dark surfaces. + ========================================================================== */ + +@media (prefers-color-scheme: dark) { + + /* --- Action button hover states: softer colors, more visible tinted backgrounds --- */ + .ap-card__action--reply:hover { + background: color-mix(in srgb, var(--color-primary) 18%, transparent); + color: var(--color-primary-on-background); + } + + .ap-card__action--boost:hover { + background: color-mix(in srgb, var(--color-green50) 18%, transparent); + color: var(--color-green90); + } + + .ap-card__action--like:hover { + background: color-mix(in srgb, var(--color-red45) 18%, transparent); + color: var(--color-red80); + } + + .ap-card__action--save:hover { + background: color-mix(in srgb, var(--color-primary) 18%, transparent); + color: var(--color-primary-on-background); + } + + /* --- Active interaction states --- */ + .ap-card__action--like.ap-card__action--active { + background: color-mix(in srgb, var(--color-red45) 18%, transparent); + color: var(--color-red80); + } + + .ap-card__action--boost.ap-card__action--active { + background: color-mix(in srgb, var(--color-green50) 18%, transparent); + color: var(--color-green90); + } + + .ap-card__action--save.ap-card__action--active { + background: color-mix(in srgb, var(--color-primary) 18%, transparent); + color: var(--color-primary-on-background); + } + + /* --- Post-type left border accents: desaturated for dark surfaces --- */ + .ap-card--note, + .ap-card--note:hover { + border-left-color: var(--color-purple90); + } + + .ap-card--article, + .ap-card--article:hover { + border-left-color: var(--color-green90); + } + + .ap-card--boost, + .ap-card--boost:hover { + border-left-color: var(--color-yellow90); + } + + .ap-card--reply, + .ap-card--reply:hover { + border-left-color: var(--color-primary-on-background); + } + + /* --- Notification unread glow: toned down --- */ + .ap-notification--unread { + border-color: var(--color-yellow90); + box-shadow: 0 0 6px 0 color-mix(in srgb, var(--color-yellow50) 15%, transparent); + } + + /* --- Post detail highlight ring: softened --- */ + .ap-post-detail__main .ap-card { + border-color: color-mix(in srgb, var(--color-primary) 50%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 50%, transparent); + } + + /* --- Card shadows: use light tint instead of black --- */ + .ap-card { + box-shadow: 0 1px 2px hsl(var(--tint-neutral) 90% / 0.04); + } + + .ap-card:hover { + box-shadow: 0 2px 8px hsl(var(--tint-neutral) 90% / 0.06); + } + + /* --- Tab badge federated: soften purple --- */ + .ap-tab__badge--federated { + color: var(--color-purple90); + background: color-mix(in srgb, var(--color-purple45) 18%, transparent); + } +} diff --git a/assets/css/explore.css b/assets/css/explore.css new file mode 100644 index 0000000..e849965 --- /dev/null +++ b/assets/css/explore.css @@ -0,0 +1,530 @@ +/* ========================================================================== + Explore Page + ========================================================================== */ + +.ap-explore-header { + margin-bottom: var(--space-m); +} + +.ap-explore-header__title { + font-size: var(--font-size-xl); + margin: 0 0 var(--space-xs); +} + +.ap-explore-header__desc { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin: 0; +} + +.ap-explore-form { + background: var(--color-offset); + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + margin-bottom: var(--space-m); + padding: var(--space-m); +} + +.ap-explore-form__row { + align-items: center; + display: flex; + gap: var(--space-s); + flex-wrap: wrap; +} + +.ap-explore-form__input { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + box-sizing: border-box; + font-size: var(--font-size-m); + min-width: 0; + padding: var(--space-xs) var(--space-s); + width: 100%; +} + +.ap-explore-form__scope { + display: flex; + gap: var(--space-s); +} + +.ap-explore-form__scope-label { + align-items: center; + cursor: pointer; + display: flex; + font-size: var(--font-size-s); + gap: var(--space-xs); +} + +.ap-explore-form__btn { + background: var(--color-primary); + border: none; + border-radius: var(--border-radius-small); + color: var(--color-on-primary); + cursor: pointer; + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-m); + white-space: nowrap; +} + +.ap-explore-form__btn:hover { + opacity: 0.85; +} + +.ap-explore-error { + background: color-mix(in srgb, var(--color-error) 10%, transparent); + border: var(--border-width-thin) solid var(--color-error); + border-radius: var(--border-radius-small); + color: var(--color-error); + margin-bottom: var(--space-m); + padding: var(--space-s) var(--space-m); +} + +@media (max-width: 640px) { + .ap-explore-form__row { + flex-direction: column; + align-items: stretch; + } + + .ap-explore-form__btn { + width: 100%; + } +} + +/* ---------- Autocomplete dropdown ---------- */ + +.ap-explore-autocomplete { + flex: 1; + min-width: 0; + position: relative; +} + +.ap-explore-autocomplete__dropdown { + background: var(--color-background); + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + box-shadow: 0 4px 12px hsl(var(--tint-neutral) 10% / 0.15); + left: 0; + max-height: 320px; + overflow-y: auto; + position: absolute; + right: 0; + top: 100%; + z-index: 100; +} + +.ap-explore-autocomplete__item { + align-items: center; + background: none; + border: none; + color: var(--color-on-background); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: var(--font-size-s); + gap: var(--space-s); + padding: var(--space-s) var(--space-m); + text-align: left; + width: 100%; +} + +.ap-explore-autocomplete__item:hover, +.ap-explore-autocomplete__item--highlighted { + background: var(--color-offset); +} + +.ap-explore-autocomplete__domain { + flex-shrink: 0; + font-weight: 600; +} + +.ap-explore-autocomplete__meta { + color: var(--color-on-offset); + display: flex; + flex: 1; + gap: var(--space-xs); + min-width: 0; +} + +.ap-explore-autocomplete__software { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + border-radius: var(--border-radius-small); + font-size: var(--font-size-xs); + padding: 1px 6px; + white-space: nowrap; +} + +.ap-explore-autocomplete__mau { + font-size: var(--font-size-xs); + white-space: nowrap; +} + +.ap-explore-autocomplete__status { + flex-shrink: 0; + font-size: var(--font-size-s); +} + +.ap-explore-autocomplete__checking { + opacity: 0.5; +} + +/* ---------- Popular accounts autocomplete ---------- */ + +.ap-lookup-autocomplete { + flex: 1; + min-width: 0; + position: relative; +} + +.ap-lookup-autocomplete__dropdown { + background: var(--color-background); + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + box-shadow: 0 4px 12px hsl(var(--tint-neutral) 10% / 0.15); + left: 0; + max-height: 320px; + overflow-y: auto; + position: absolute; + right: 0; + top: 100%; + z-index: 100; +} + +.ap-lookup-autocomplete__item { + align-items: center; + background: none; + border: none; + color: var(--color-on-background); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: var(--font-size-s); + gap: var(--space-s); + padding: var(--space-s) var(--space-m); + text-align: left; + width: 100%; +} + +.ap-lookup-autocomplete__item:hover, +.ap-lookup-autocomplete__item--highlighted { + background: var(--color-offset); +} + +.ap-lookup-autocomplete__avatar { + border-radius: 50%; + flex-shrink: 0; + height: 28px; + object-fit: cover; + width: 28px; +} + +.ap-lookup-autocomplete__info { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.ap-lookup-autocomplete__name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-lookup-autocomplete__handle { + color: var(--color-on-offset); + font-size: var(--font-size-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-lookup-autocomplete__followers { + color: var(--color-on-offset); + flex-shrink: 0; + font-size: var(--font-size-xs); + white-space: nowrap; +} + +/* ========================================================================== + Explore: Tabbed Design + ========================================================================== */ + +/* Tab bar wrapper: enables position:relative for fade gradient overlay */ +.ap-explore-tabs-container { + position: relative; +} + +/* Tab bar with right-edge fade to indicate horizontal overflow */ +.ap-explore-tabs-nav { + padding-right: var(--space-l); + position: relative; +} + +.ap-explore-tabs-nav::after { + background: linear-gradient(to right, transparent, var(--color-background) 80%); + content: ""; + height: 100%; + pointer-events: none; + position: absolute; + right: 0; + top: 0; + width: 40px; +} + +/* Tab wrapper: holds tab button + reorder/close controls together */ +.ap-tab-wrapper { + align-items: stretch; + display: inline-flex; + position: relative; +} + +/* Show controls on hover or when the tab is active */ +.ap-tab-controls { + align-items: center; + display: none; + gap: 1px; +} + +.ap-tab-wrapper:hover .ap-tab-controls, +.ap-tab-wrapper:focus-within .ap-tab-controls { + display: flex; +} + +/* Individual control buttons (↑ ↓ ×) */ +.ap-tab-control { + background: none; + border: none; + color: var(--color-on-offset); + cursor: pointer; + font-size: var(--font-size-xs); + line-height: 1; + padding: 2px 4px; +} + +.ap-tab-control:hover { + color: var(--color-on-background); +} + +.ap-tab-control:disabled { + cursor: default; + opacity: 0.3; +} + +.ap-tab-control--remove { + color: var(--color-on-offset); + font-size: var(--font-size-s); +} + +.ap-tab-control--remove:hover { + color: var(--color-error); +} + +/* Truncate long domain names in tab labels */ +.ap-tab__label { + display: inline-block; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Scope badges on instance tabs */ +.ap-tab__badge { + border-radius: var(--border-radius-small); + font-size: 0.65em; + font-weight: 700; + letter-spacing: 0.02em; + margin-left: var(--space-xs); + padding: 1px 4px; + text-transform: uppercase; + vertical-align: middle; +} + +.ap-tab__badge--local { + background: color-mix(in srgb, var(--color-primary) 15%, transparent); + color: var(--color-primary-on-background); +} + +.ap-tab__badge--federated { + background: color-mix(in srgb, var(--color-purple45) 15%, transparent); + color: var(--color-purple45); +} + +/* +# button for adding hashtag tabs */ +.ap-tab--add { + font-family: monospace; + font-weight: 700; + letter-spacing: -0.05em; +} + +/* Inline hashtag form that appears when +# is clicked */ +.ap-tab-add-hashtag { + align-items: center; + display: inline-flex; + gap: var(--space-xs); +} + +.ap-tab-hashtag-form { + align-items: center; + display: flex; + gap: var(--space-xs); +} + +.ap-tab-hashtag-form__prefix { + color: var(--color-on-offset); + font-weight: 600; +} + +.ap-tab-hashtag-form__input { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + font-family: inherit; + font-size: var(--font-size-s); + padding: 2px var(--space-s); + width: 8em; +} + +.ap-tab-hashtag-form__input:focus { + border-color: var(--color-primary); + outline: 2px solid var(--color-primary); + outline-offset: -1px; +} + +.ap-tab-hashtag-form__btn { + background: var(--color-primary); + border: none; + border-radius: var(--border-radius-small); + color: var(--color-on-primary); + cursor: pointer; + font-family: inherit; + font-size: var(--font-size-s); + padding: 2px var(--space-s); + white-space: nowrap; +} + +.ap-tab-hashtag-form__btn:hover { + opacity: 0.85; +} + +/* "Pin as tab" button in search results area */ +.ap-explore-pin-bar { + margin-bottom: var(--space-s); +} + +.ap-explore-pin-btn { + background: none; + border: var(--border-width-thin) solid var(--color-primary-on-background); + border-radius: var(--border-radius-small); + color: var(--color-primary-on-background); + cursor: pointer; + font-family: inherit; + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-m); +} + +.ap-explore-pin-btn:hover { + background: color-mix(in srgb, var(--color-primary) 10%, transparent); +} + +.ap-explore-pin-btn:disabled { + cursor: default; + opacity: 0.6; +} + +/* Hashtag form row inside the search form */ +.ap-explore-form__hashtag-row { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + margin-top: var(--space-s); +} + +.ap-explore-form__hashtag-label { + color: var(--color-on-offset); + font-size: var(--font-size-s); + white-space: nowrap; +} + +.ap-explore-form__hashtag-prefix { + color: var(--color-on-offset); + font-weight: 600; +} + +.ap-explore-form__hashtag-hint { + color: var(--color-on-offset); + font-size: var(--font-size-xs); + flex-basis: 100%; +} + +.ap-explore-form__input--hashtag { + max-width: 200px; + width: auto; +} + +/* Tab panel containers */ +.ap-explore-instance-panel, +.ap-explore-hashtag-panel { + min-height: 120px; +} + +/* Loading state */ +.ap-explore-tab-loading { + align-items: center; + color: var(--color-on-offset); + display: flex; + justify-content: center; + padding: var(--space-xl); +} + +.ap-explore-tab-loading--more { + padding-block: var(--space-m); +} + +.ap-explore-tab-loading__text { + font-size: var(--font-size-s); +} + +/* Error state */ +.ap-explore-tab-error { + align-items: center; + display: flex; + flex-direction: column; + gap: var(--space-s); + padding: var(--space-xl); +} + +.ap-explore-tab-error__message { + color: var(--color-error); + font-size: var(--font-size-s); + margin: 0; +} + +.ap-explore-tab-error__retry { + background: none; + border: var(--border-width-thin) solid var(--color-primary-on-background); + border-radius: var(--border-radius-small); + color: var(--color-primary-on-background); + cursor: pointer; + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-s); +} + +.ap-explore-tab-error__retry:hover { + background: color-mix(in srgb, var(--color-primary) 10%, transparent); +} + +/* Empty state */ +.ap-explore-tab-empty { + color: var(--color-on-offset); + font-size: var(--font-size-s); + padding: var(--space-xl); + text-align: center; +} + +/* Infinite scroll sentinel — zero height, invisible */ +.ap-tab-sentinel { + height: 1px; + visibility: hidden; +} diff --git a/assets/css/features.css b/assets/css/features.css new file mode 100644 index 0000000..715f43f --- /dev/null +++ b/assets/css/features.css @@ -0,0 +1,436 @@ +/* ========================================================================== + Post Detail View — Thread Layout + ========================================================================== */ + +.ap-post-detail__back { + margin-bottom: var(--space-m); +} + +.ap-post-detail__back-link { + color: var(--color-primary-on-background); + font-size: var(--font-size-s); + text-decoration: none; +} + +.ap-post-detail__back-link:hover { + text-decoration: underline; +} + +.ap-post-detail__not-found { + background: var(--color-offset); + border-radius: var(--border-radius-small); + color: var(--color-on-offset); + padding: var(--space-l); + text-align: center; +} + +.ap-post-detail__section-title { + color: var(--color-on-offset); + font-size: var(--font-size-s); + font-weight: 600; + margin: var(--space-m) 0 var(--space-s); + padding-bottom: var(--space-xs); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Parent posts — indented with left border to show thread chain */ +.ap-post-detail__parents { + border-left: 3px solid var(--color-outline); + margin-bottom: var(--space-s); + padding-left: var(--space-m); +} + +.ap-post-detail__parent-item .ap-card { + opacity: 0.85; +} + +/* Main post — highlighted */ +.ap-post-detail__main { + margin-bottom: var(--space-m); +} + +.ap-post-detail__main .ap-card { + border-color: var(--color-primary); + box-shadow: 0 0 0 1px var(--color-primary); +} + +/* Replies — indented from the other side */ +.ap-post-detail__replies { + margin-left: var(--space-l); +} + +.ap-post-detail__reply-item { + border-left: 2px solid var(--color-outline); + padding-left: var(--space-m); + margin-bottom: var(--space-xs); +} + +/* ========================================================================== + Tag Timeline Header + ========================================================================== */ + +.ap-tag-header { + align-items: flex-start; + background: var(--color-offset); + border-bottom: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + display: flex; + gap: var(--space-m); + justify-content: space-between; + margin-bottom: var(--space-m); + padding: var(--space-m); +} + +.ap-tag-header__title { + font-size: var(--font-size-xl); + font-weight: 600; + margin: 0 0 var(--space-xs); +} + +.ap-tag-header__count { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin: 0; +} + +.ap-tag-header__actions { + align-items: center; + display: flex; + flex-shrink: 0; + gap: var(--space-s); +} + +.ap-tag-header__follow-btn { + background: var(--color-primary); + border: none; + border-radius: var(--border-radius-small); + color: var(--color-on-primary, var(--color-neutral99)); + cursor: pointer; + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-s); +} + +.ap-tag-header__follow-btn:hover { + opacity: 0.85; +} + +.ap-tag-header__unfollow-btn { + background: transparent; + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-background); + cursor: pointer; + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-s); +} + +.ap-tag-header__unfollow-btn:hover { + border-color: var(--color-on-background); +} + +.ap-tag-header__back { + color: var(--color-on-offset); + font-size: var(--font-size-s); + text-decoration: none; +} + +.ap-tag-header__back:hover { + color: var(--color-on-background); + text-decoration: underline; +} + +@media (max-width: 640px) { + .ap-tag-header { + flex-direction: column; + gap: var(--space-s); + } + + .ap-tag-header__actions { + flex-wrap: wrap; + } +} + +/* ========================================================================== + Reader Tools Bar (Explore link, etc.) + ========================================================================== */ + +.ap-reader-tools { + display: flex; + gap: var(--space-s); + justify-content: flex-end; + margin-bottom: var(--space-s); +} + +.ap-reader-tools__explore { + color: var(--color-on-offset); + font-size: var(--font-size-s); + text-decoration: none; +} + +.ap-reader-tools__explore:hover { + color: var(--color-on-background); + text-decoration: underline; +} + +/* Followed tags bar */ +.ap-followed-tags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) 0; + margin-bottom: var(--space-s); + font-size: var(--font-size-s); +} + +.ap-followed-tags__label { + color: var(--color-on-offset); + font-weight: 600; +} + +/* ========================================================================== + New Posts Banner + ========================================================================== */ + +.ap-new-posts-banner { + left: 0; + position: sticky; + right: 0; + top: 0; + z-index: 10; +} + +.ap-new-posts-banner__btn { + background: var(--color-primary); + border: none; + border-radius: var(--border-radius-small); + color: var(--color-on-primary); + cursor: pointer; + display: block; + font-family: inherit; + font-size: var(--font-size-s); + margin: 0 auto var(--space-s); + padding: var(--space-xs) var(--space-m); + text-align: center; + width: auto; +} + +.ap-new-posts-banner__btn:hover { + opacity: 0.9; +} + +/* ========================================================================== + Read State + ========================================================================== */ + +.ap-card--read { + opacity: 0.7; + transition: opacity 0.3s ease; +} + +.ap-card--read:hover { + opacity: 1; +} + +/* ========================================================================== + Unread Toggle + ========================================================================== */ + +.ap-unread-toggle { + margin-left: auto; +} + +.ap-unread-toggle--active { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + font-weight: 600; +} + +/* ========================================================================== + Quote Embeds + ========================================================================== */ + +.ap-quote-embed { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + margin-top: var(--space-s); + overflow: hidden; + transition: border-color 0.15s ease; +} + +.ap-quote-embed:hover { + border-color: var(--color-outline-variant); +} + +.ap-quote-embed--pending { + border-style: dashed; +} + +.ap-quote-embed__link { + color: inherit; + display: block; + padding: var(--space-s) var(--space-m); + text-decoration: none; +} + +.ap-quote-embed__link:hover { + background: color-mix(in srgb, var(--color-offset) 50%, transparent); +} + +.ap-quote-embed__author { + align-items: center; + display: flex; + gap: var(--space-xs); + margin-bottom: var(--space-xs); +} + +.ap-quote-embed__avatar { + border-radius: 50%; + flex-shrink: 0; + height: 24px; + object-fit: cover; + width: 24px; +} + +.ap-quote-embed__avatar--default { + align-items: center; + background: var(--color-offset); + color: var(--color-on-offset); + display: inline-flex; + font-size: var(--font-size-xs); + font-weight: 600; + justify-content: center; +} + +.ap-quote-embed__author-info { + flex: 1; + min-width: 0; +} + +.ap-quote-embed__name { + font-size: var(--font-size-s); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-quote-embed__handle { + color: var(--color-on-offset); + font-size: var(--font-size-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-quote-embed__time { + color: var(--color-on-offset); + flex-shrink: 0; + font-size: var(--font-size-xs); + white-space: nowrap; +} + +.ap-quote-embed__title { + font-size: var(--font-size-s); + font-weight: 600; + margin: 0 0 var(--space-xs); +} + +.ap-quote-embed__content { + color: var(--color-on-background); + font-size: var(--font-size-s); + line-height: calc(4 / 3 * 1em); + max-height: calc(1.333em * 6); + overflow: hidden; +} + +.ap-quote-embed__content a { + display: inline; +} + +.ap-quote-embed__content a span { + display: inline; +} + +.ap-quote-embed__content p { + margin: 0 0 var(--space-xs); +} + +.ap-quote-embed__content p:last-child { + margin-bottom: 0; +} + +.ap-quote-embed__media { + margin-top: var(--space-xs); +} + +.ap-quote-embed__photo { + border-radius: var(--border-radius-small); + max-height: 160px; + max-width: 100%; + object-fit: cover; +} + +/* ========================================================================== + Poll / Question + ========================================================================== */ + +.ap-poll { + margin-top: var(--space-s); +} + +.ap-poll__option { + position: relative; + padding: var(--space-xs) var(--space-s); + margin-bottom: var(--space-xs); + border-radius: var(--border-radius-small); + background: var(--color-offset); + overflow: hidden; +} + +.ap-poll__bar { + position: absolute; + top: 0; + left: 0; + bottom: 0; + background: var(--color-primary); + opacity: 0.15; + border-radius: var(--border-radius-small); +} + +.ap-poll__label { + position: relative; + font-size: var(--font-size-s); + color: var(--color-on-background); +} + +.ap-poll__votes { + position: relative; + float: right; + font-size: var(--font-size-s); + font-weight: 600; + color: var(--color-on-offset); +} + +.ap-poll__footer { + font-size: var(--font-size-xs); + color: var(--color-on-offset); + margin-top: var(--space-xs); +} + +/* Hashtag tab sources info line */ +.ap-hashtag-sources { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin: 0; + padding: var(--space-s) 0 var(--space-xs); +} + +/* Custom emoji */ +.ap-custom-emoji { + height: 1.2em; + width: auto; + vertical-align: middle; + display: inline; + margin: 0 0.05em; +} diff --git a/assets/css/federation.css b/assets/css/federation.css new file mode 100644 index 0000000..9fab396 --- /dev/null +++ b/assets/css/federation.css @@ -0,0 +1,242 @@ +/* ========================================================================== + Federation Management + ========================================================================== */ + +.ap-federation__section { + margin-block-end: var(--space-l); +} + +.ap-federation__section h2 { + margin-block-end: var(--space-s); +} + +.ap-federation__stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: var(--space-s); +} + +.ap-federation__stat-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-xs); + padding: var(--space-s); + background: var(--color-offset); + border-radius: var(--border-radius-small); + text-align: center; +} + +.ap-federation__stat-count { + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--color-on-background); +} + +.ap-federation__stat-label { + font-size: var(--font-size-s); + color: var(--color-on-offset); + word-break: break-word; +} + +.ap-federation__actions-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-s); + align-items: center; +} + +.ap-federation__result { + margin-block-start: var(--space-xs); + color: var(--color-green50); + font-size: var(--font-size-s); +} + +.ap-federation__error { + margin-block-start: var(--space-xs); + color: var(--color-red45); + font-size: var(--font-size-s); +} + +.ap-federation__lookup-form { + display: flex; + gap: var(--space-s); +} + +.ap-federation__lookup-input { + flex: 1; + min-width: 0; + padding: 0.5rem 0.75rem; + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + font: inherit; + color: var(--color-on-background); + background: var(--color-background); +} + +.ap-federation__json-view { + margin-block-start: var(--space-s); + padding: var(--space-m); + background: var(--color-offset); + border-radius: var(--border-radius-small); + font-family: monospace; + font-size: var(--font-size-s); + color: var(--color-on-background); + max-height: 24rem; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.ap-federation__posts-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.ap-federation__post-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-m); + padding: var(--space-s); + background: var(--color-offset); + border-radius: var(--border-radius-small); +} + +.ap-federation__post-info { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + +.ap-federation__post-title { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ap-federation__post-meta { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: var(--font-size-s); + color: var(--color-on-offset); +} + +.ap-federation__post-actions { + display: flex; + gap: var(--space-xs); + flex-shrink: 0; +} + +.ap-federation__post-btn { + padding: var(--space-xs) var(--space-s); + font-size: var(--font-size-s); + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + background: var(--color-background); + color: var(--color-on-background); + cursor: pointer; +} + +.ap-federation__post-btn:hover { + background: var(--color-offset); +} + +.ap-federation__post-btn--danger { + color: var(--color-red45); + border-color: var(--color-red45); +} + +.ap-federation__post-btn--danger:hover { + background: color-mix(in srgb, var(--color-red45) 10%, transparent); +} + +.ap-federation__modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: hsl(var(--tint-neutral) 10% / 0.5); +} + +.ap-federation__modal { + width: min(90vw, 48rem); + max-height: 80vh; + display: flex; + flex-direction: column; + background: var(--color-background); + border-radius: var(--border-radius-small); + box-shadow: 0 4px 24px hsl(var(--tint-neutral) 10% / 0.2); +} + +.ap-federation__modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-s) var(--space-m); + border-block-end: var(--border-width-thin) solid var(--color-outline); +} + +.ap-federation__modal-header h3 { + margin: 0; + font-size: var(--font-size-m); +} + +.ap-federation__modal-close { + font-size: var(--font-size-xl); + line-height: 1; + padding: 0 var(--space-xs); + border: none; + background: none; + color: var(--color-on-offset); + cursor: pointer; +} + +.ap-federation__modal .ap-federation__json-view { + margin: 0; + border-radius: 0 0 var(--border-radius-small) var(--border-radius-small); + flex: 1; + overflow: auto; +} + +@media (max-width: 40rem) { + .ap-federation__post-row { + flex-direction: column; + align-items: flex-start; + } + + .ap-federation__lookup-form { + flex-direction: column; + } +} + +/* Follow request approve/reject actions */ +.ap-follow-request { + margin-block-end: var(--space-m); +} + +.ap-follow-request__actions { + display: flex; + gap: var(--space-s); + margin-block-start: var(--space-xs); + padding-inline-start: var(--space-l); +} + +.ap-follow-request__form { + display: inline; +} + +.button--danger { + background-color: var(--color-red45); + color: white; +} + +.button--danger:hover { + background-color: var(--color-red35, #c0392b); +} diff --git a/assets/css/interactions.css b/assets/css/interactions.css new file mode 100644 index 0000000..d04313e --- /dev/null +++ b/assets/css/interactions.css @@ -0,0 +1,236 @@ +/* ========================================================================== + Tags + ========================================================================== */ + +.ap-card__tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + margin-bottom: var(--space-s); +} + +.ap-card__tag { + background: var(--color-offset-variant); + border-radius: var(--border-radius-large); + color: var(--color-on-offset); + font-size: var(--font-size-s); + padding: 2px var(--space-xs); + text-decoration: none; +} + +.ap-card__tag:hover { + background: var(--color-offset-variant-darker); + color: var(--color-on-background); +} + +.ap-card__mention { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + border-radius: var(--border-radius-large); + color: var(--color-primary-on-background); + font-size: var(--font-size-s); + padding: 2px var(--space-xs); + text-decoration: none; +} + +.ap-card__mention:hover { + background: color-mix(in srgb, var(--color-primary) 22%, transparent); + color: var(--color-primary-on-background); +} + +.ap-card__mention--legacy { + cursor: default; + opacity: 0.7; +} + +/* Hashtag stuffing collapse */ +.ap-hashtag-overflow { + margin: var(--space-xs) 0; + font-size: var(--font-size-s); +} + +.ap-hashtag-overflow summary { + cursor: pointer; + color: var(--color-on-offset); + list-style: none; +} + +.ap-hashtag-overflow summary::before { + content: "▸ "; +} + +.ap-hashtag-overflow[open] summary::before { + content: "▾ "; +} + +.ap-hashtag-overflow p { + margin-top: var(--space-xs); +} + +/* ========================================================================== + Interaction Buttons + ========================================================================== */ + +.ap-card__actions { + border-top: var(--border-width-thin) solid var(--color-outline); + display: flex; + flex-wrap: wrap; + gap: 2px; + padding-top: var(--space-s); +} + +.ap-card__action { + align-items: center; + background: transparent; + border: 0; + border-radius: var(--border-radius-small); + color: var(--color-on-offset); + cursor: pointer; + display: inline-flex; + font-size: var(--font-size-s); + gap: 0.3em; + min-height: 36px; + padding: 0.25em 0.6em; + text-decoration: none; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.ap-card__action:hover { + background: var(--color-offset-variant); + color: var(--color-on-background); +} + +/* Color-coded hover states per action type */ +.ap-card__action--reply:hover { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + color: var(--color-primary); +} + +.ap-card__action--boost:hover { + background: color-mix(in srgb, var(--color-green50) 12%, transparent); + color: var(--color-green50); +} + +.ap-card__action--like:hover { + background: color-mix(in srgb, var(--color-red45) 12%, transparent); + color: var(--color-red45); +} + +.ap-card__action--link:hover { + background: var(--color-offset-variant); + color: var(--color-on-background); +} + +.ap-card__action--save:hover { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + color: var(--color-primary); +} + +/* Active interaction states */ +.ap-card__action--like.ap-card__action--active { + background: color-mix(in srgb, var(--color-red45) 12%, transparent); + color: var(--color-red45); +} + +.ap-card__action--boost.ap-card__action--active { + background: color-mix(in srgb, var(--color-green50) 12%, transparent); + color: var(--color-green50); +} + +.ap-card__action--save.ap-card__action--active { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + color: var(--color-primary); +} + +.ap-card__action:disabled { + cursor: wait; + opacity: 0.5; +} + +/* Interaction counts */ +.ap-card__count { + font-size: var(--font-size-xs); + color: inherit; + opacity: 0.7; + margin-left: 0.1em; + font-variant-numeric: tabular-nums; +} + +/* Error message */ +.ap-card__action-error { + color: var(--color-error); + font-size: var(--font-size-s); + width: 100%; +} + +/* ========================================================================== + Pagination + ========================================================================== */ + +.ap-pagination { + border-top: var(--border-width-thin) solid var(--color-outline); + display: flex; + gap: var(--space-m); + justify-content: space-between; + margin-top: var(--space-m); + padding-top: var(--space-m); +} + +.ap-pagination a { + color: var(--color-primary-on-background); + text-decoration: none; +} + +.ap-pagination a:hover { + text-decoration: underline; +} + +/* Hidden once Alpine is active (JS replaces with infinite scroll) */ +.ap-pagination--js-hidden { + /* Shown by default for no-JS fallback — Alpine hides via display:none */ +} + +/* ========================================================================== + Infinite Scroll / Load More + ========================================================================== */ + +.ap-load-more { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-s); + padding: var(--space-m) 0; +} + +.ap-load-more__sentinel { + height: 1px; + width: 100%; +} + +.ap-load-more__btn { + background: var(--color-offset); + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-background); + cursor: pointer; + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-m); + transition: background 0.15s; +} + +.ap-load-more__btn:hover:not(:disabled) { + background: var(--color-offset-variant); +} + +.ap-load-more__btn:disabled { + cursor: wait; + opacity: 0.6; +} + +.ap-load-more__done { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin: 0; + text-align: center; +} diff --git a/assets/css/media.css b/assets/css/media.css new file mode 100644 index 0000000..44bf8fe --- /dev/null +++ b/assets/css/media.css @@ -0,0 +1,315 @@ +/* ========================================================================== + Photo Gallery + ========================================================================== */ + +.ap-card__gallery { + border-radius: var(--border-radius-small); + display: grid; + gap: 2px; + margin-bottom: var(--space-s); + overflow: hidden; +} + +.ap-card__gallery-link { + appearance: none; + background: none; + border: 0; + cursor: pointer; + display: block; + padding: 0; + position: relative; +} + +.ap-card__gallery img { + background: var(--color-offset-variant); + display: block; + height: 280px; + object-fit: cover; + width: 100%; + transition: filter 0.2s ease; +} + +@media (max-width: 480px) { + .ap-card__gallery img { + height: 180px; + } +} + +.ap-card__gallery-link:hover img { + filter: brightness(0.92); +} + +.ap-card__gallery-link--more::after { + background: hsl(var(--tint-neutral) 10% / 0.5); + bottom: 0; + content: ""; + left: 0; + position: absolute; + right: 0; + top: 0; +} + +.ap-card__gallery-more { + color: var(--color-neutral99); + font-size: 1.5em; + font-weight: 600; + left: 50%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + z-index: 1; +} + +/* 1 photo */ +.ap-card__gallery--1 { + grid-template-columns: 1fr; +} + +.ap-card__gallery--1 img { + height: auto; + max-height: 500px; +} + +/* 2 photos — side by side */ +.ap-card__gallery--2 { + grid-template-columns: 1fr 1fr; +} + +/* 3 photos — one large, two small */ +.ap-card__gallery--3 { + grid-template-columns: 2fr 1fr; + grid-template-rows: 1fr 1fr; +} + +.ap-card__gallery--3 img:first-child { + grid-row: 1 / 3; + height: 100%; +} + +/* 4+ photos — 2x2 grid */ +.ap-card__gallery--4 { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; +} + +/* ========================================================================== + Photo Lightbox + ========================================================================== */ + +[x-cloak] { + display: none !important; +} + +.ap-lightbox { + align-items: center; + background: hsl(var(--tint-neutral) 10% / 0.92); + display: flex; + inset: 0; + justify-content: center; + position: fixed; + z-index: 9999; +} + +.ap-lightbox__img { + max-height: 90vh; + max-width: 95vw; + object-fit: contain; +} + +.ap-lightbox__close { + background: none; + border: 0; + color: white; + cursor: pointer; + font-size: 2rem; + line-height: 1; + padding: var(--space-s); + position: absolute; + right: var(--space-m); + top: var(--space-m); +} + +.ap-lightbox__close:hover { + opacity: 0.7; +} + +.ap-lightbox__prev, +.ap-lightbox__next { + background: none; + border: 0; + color: white; + cursor: pointer; + font-size: 3rem; + line-height: 1; + padding: var(--space-m); + position: absolute; + top: 50%; + transform: translateY(-50%); +} + +.ap-lightbox__prev { + left: var(--space-s); +} + +.ap-lightbox__next { + right: var(--space-s); +} + +.ap-lightbox__prev:hover, +.ap-lightbox__next:hover { + opacity: 0.7; +} + +.ap-lightbox__counter { + bottom: var(--space-m); + color: white; + font-size: var(--font-size-s); + left: 50%; + position: absolute; + transform: translateX(-50%); +} + +/* ========================================================================== + Link Preview Card + ========================================================================== */ + +.ap-link-previews { + margin-bottom: var(--space-s); +} + +.ap-link-preview { + display: flex; + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + overflow: hidden; + text-decoration: none; + color: inherit; + transition: border-color 0.2s ease; +} + +.ap-link-preview:hover { + border-color: var(--color-primary); +} + +.ap-link-preview__text { + flex: 1; + min-width: 0; + padding: var(--space-s) var(--space-m); + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.2em; +} + +.ap-link-preview__title { + font-weight: 600; + font-size: var(--font-size-s); + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-link-preview__desc { + font-size: var(--font-size-s); + color: var(--color-on-offset); + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.ap-link-preview__domain { + font-size: var(--font-size-xs); + color: var(--color-on-offset); + margin: 0; + display: flex; + align-items: center; + gap: 0.3em; +} + +.ap-link-preview__favicon { + width: 14px; + height: 14px; +} + +.ap-link-preview__image { + flex-shrink: 0; + width: 120px; +} + +.ap-link-preview__image img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* ========================================================================== + Video Embed + ========================================================================== */ + +.ap-card__video { + margin-bottom: var(--space-s); +} + +.ap-card__video video { + border-radius: var(--border-radius-small); + max-height: 400px; + width: 100%; +} + +/* ========================================================================== + Audio Player + ========================================================================== */ + +.ap-card__audio { + margin-bottom: var(--space-s); +} + +.ap-card__audio audio { + width: 100%; +} + +/* Gallery items — positioned for ALT badge overlay */ +.ap-card__gallery-item { + position: relative; +} + +/* ALT text badges */ +.ap-media__alt-badge { + position: absolute; + bottom: 0.5rem; + left: 0.5rem; + background: hsl(var(--tint-neutral) 10% / 0.7); + color: var(--color-neutral99); + font-size: 0.65rem; + font-weight: 700; + padding: 0.15rem 0.35rem; + border-radius: var(--border-radius-small); + border: none; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.03em; + z-index: 1; +} + +.ap-media__alt-badge:hover { + background: hsl(var(--tint-neutral) 10% / 0.9); +} + +.ap-media__alt-text { + position: absolute; + bottom: 2.2rem; + left: 0.5rem; + right: 0.5rem; + background: hsl(var(--tint-neutral) 10% / 0.85); + color: var(--color-neutral99); + font-size: var(--font-size-s); + padding: 0.5rem; + border-radius: var(--border-radius-small); + max-height: 8rem; + overflow-y: auto; + z-index: 2; +} diff --git a/assets/css/messages.css b/assets/css/messages.css new file mode 100644 index 0000000..7ac6fd4 --- /dev/null +++ b/assets/css/messages.css @@ -0,0 +1,158 @@ +/* ========================================================================== + Messages + ========================================================================== */ + +.ap-messages__layout { + display: grid; + grid-template-columns: 240px 1fr; + gap: var(--space-m); + min-height: 300px; +} + +.ap-messages__sidebar { + border-right: var(--border-width-thin) solid var(--color-outline); + display: flex; + flex-direction: column; + gap: 2px; + padding-right: var(--space-m); + overflow-y: auto; + max-height: 70vh; +} + +.ap-messages__partner { + align-items: center; + border-radius: var(--border-radius-small); + color: var(--color-on-background); + display: flex; + gap: var(--space-s); + padding: var(--space-s); + text-decoration: none; + transition: background 0.15s ease; +} + +.ap-messages__partner:hover { + background: var(--color-offset); +} + +.ap-messages__partner--active { + background: var(--color-offset); + border-left: 3px solid var(--color-primary); + font-weight: var(--font-weight-bold); +} + +.ap-messages__partner-avatar { + flex-shrink: 0; + height: 32px; + position: relative; + width: 32px; +} + +.ap-messages__partner-avatar img { + border-radius: 50%; + height: 100%; + object-fit: cover; + position: absolute; + inset: 0; + width: 100%; + z-index: 1; +} + +.ap-messages__partner-initial { + align-items: center; + background: var(--color-offset-variant); + border-radius: 50%; + color: var(--color-on-offset); + display: flex; + font-size: var(--font-size-s); + height: 100%; + justify-content: center; + width: 100%; +} + +.ap-messages__partner-info { + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +.ap-messages__partner-name { + font-size: var(--font-size-s); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-messages__partner-handle { + color: var(--color-on-offset); + font-size: var(--font-size-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-messages__content { + min-width: 0; +} + +.ap-message--outbound { + border-left: 3px solid var(--color-primary); +} + +.ap-message .ap-notification__time { + padding-right: var(--space-l); +} + +.ap-message__direction { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin-right: var(--space-xs); +} + +.ap-message__content { + color: var(--color-on-background); + font-size: var(--font-size-s); + line-height: 1.5; + margin-top: var(--space-xs); +} + +.ap-message__content p { + margin: 0 0 var(--space-xs); +} + +.ap-message__content p:last-child { + margin-bottom: 0; +} + +/* Inline mention links in DM content (Mastodon wraps @user in span inside a link) */ +.ap-message__content .h-card, +.ap-message__content a.mention, +.ap-message__content a span { + display: inline; +} + +.ap-message__content a { + overflow-wrap: break-word; +} + +@media (max-width: 640px) { + .ap-messages__layout { + grid-template-columns: 1fr; + } + + .ap-messages__sidebar { + border-bottom: var(--border-width-thin) solid var(--color-outline); + border-right: none; + flex-direction: row; + max-height: none; + overflow-x: auto; + padding-bottom: var(--space-s); + padding-right: 0; + -webkit-overflow-scrolling: touch; + } + + .ap-messages__partner { + flex-shrink: 0; + white-space: nowrap; + } +} diff --git a/assets/css/moderation.css b/assets/css/moderation.css new file mode 100644 index 0000000..413d9e9 --- /dev/null +++ b/assets/css/moderation.css @@ -0,0 +1,119 @@ +/* ========================================================================== + Moderation + ========================================================================== */ + +.ap-moderation__section { + margin-bottom: var(--space-l); +} + +.ap-moderation__section h2 { + font-size: var(--font-size-l); + margin-bottom: var(--space-s); +} + +.ap-moderation__list { + list-style: none; + margin: 0; + padding: 0; +} + +.ap-moderation__entry { + align-items: center; + border-bottom: var(--border-width-thin) solid var(--color-outline); + display: flex; + gap: var(--space-s); + justify-content: space-between; + padding: var(--space-s) 0; +} + +.ap-moderation__entry a { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-moderation__remove { + background: transparent; + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-offset); + cursor: pointer; + flex-shrink: 0; + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-s); +} + +.ap-moderation__remove:hover { + border-color: var(--color-error); + color: var(--color-error); +} + +.ap-moderation__add-form { + display: flex; + gap: var(--space-s); +} + +.ap-moderation__input { + background: var(--color-background); + border: var(--border-width-thick) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-background); + flex: 1; + font-size: var(--font-size-m); + padding: var(--space-xs) var(--space-s); +} + +.ap-moderation__add-btn { + background: var(--color-offset); + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-background); + cursor: pointer; + font-size: var(--font-size-m); + padding: var(--space-xs) var(--space-m); +} + +.ap-moderation__add-btn:hover { + background: var(--color-offset-variant); +} + +.ap-moderation__add-btn:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.ap-moderation__error { + color: var(--color-error); + font-size: var(--font-size-s); + margin-top: var(--space-xs); +} + +.ap-moderation__empty { + color: var(--color-on-offset); + font-size: var(--font-size-s); + font-style: italic; +} + +.ap-moderation__hint { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin-bottom: var(--space-s); +} + +.ap-moderation__filter-toggle { + display: flex; + gap: var(--space-m); +} + +.ap-moderation__radio { + align-items: center; + cursor: pointer; + display: flex; + gap: var(--space-xs); +} + +.ap-moderation__radio input { + accent-color: var(--color-primary); + cursor: pointer; +} diff --git a/assets/css/notifications.css b/assets/css/notifications.css new file mode 100644 index 0000000..9f390a2 --- /dev/null +++ b/assets/css/notifications.css @@ -0,0 +1,191 @@ +/* ========================================================================== + Notifications + ========================================================================== */ + +/* Notifications Toolbar */ +.ap-notifications__toolbar { + display: flex; + gap: var(--space-s); + margin-bottom: var(--space-m); +} + +.ap-notifications__btn { + background: var(--color-offset); + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-background); + cursor: pointer; + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-m); + transition: all 0.2s ease; +} + +.ap-notifications__btn:hover { + background: var(--color-offset-variant); + border-color: var(--color-outline-variant); +} + +.ap-notifications__btn--danger { + color: var(--color-error); +} + +.ap-notifications__btn--danger:hover { + border-color: var(--color-error); +} + +.ap-notification { + align-items: flex-start; + background: var(--color-offset); + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + display: flex; + gap: var(--space-s); + padding: var(--space-m); + position: relative; +} + +.ap-notification--unread { + border-color: var(--color-yellow50); + box-shadow: 0 0 8px 0 hsl(var(--tint-yellow) 50% / 0.3); +} + +.ap-notification__avatar-wrap { + flex-shrink: 0; + position: relative; +} + +.ap-notification__avatar-wrap { + height: 40px; + width: 40px; +} + +.ap-notification__avatar { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: 50%; + height: 40px; + object-fit: cover; + width: 40px; +} + +.ap-notification__avatar-wrap > img { + position: absolute; + inset: 0; + z-index: 1; +} + +.ap-notification__avatar--default { + align-items: center; + background: var(--color-offset-variant); + color: var(--color-on-offset); + display: inline-flex; + font-size: 1.1em; + font-weight: 600; + justify-content: center; +} + +.ap-notification__type-badge { + bottom: -2px; + font-size: 0.75em; + position: absolute; + right: -4px; +} + +.ap-notification__body { + flex: 1; + min-width: 0; +} + +.ap-notification__actor { + font-weight: 600; +} + +.ap-notification__action { + color: var(--color-on-offset); +} + +.ap-notification__target { + color: var(--color-on-offset); + display: block; + font-size: var(--font-size-s); + margin-top: var(--space-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-notification__excerpt { + background: var(--color-offset-variant); + border-radius: var(--border-radius-small); + font-size: var(--font-size-s); + margin-top: var(--space-xs); + padding: var(--space-xs) var(--space-s); +} + +.ap-notification__time { + color: var(--color-on-offset); + flex-shrink: 0; + font-size: var(--font-size-xs); +} + +.ap-notification__dismiss { + position: absolute; + right: var(--space-xs); + top: var(--space-xs); +} + +.ap-notification__dismiss-btn { + background: transparent; + border: 0; + border-radius: var(--border-radius-small); + color: var(--color-on-offset); + cursor: pointer; + font-size: var(--font-size-m); + line-height: 1; + padding: 2px 6px; + transition: all 0.2s ease; +} + +.ap-notification__dismiss-btn:hover { + background: var(--color-offset-variant); + color: var(--color-error); +} + +.ap-notification__actions { + display: flex; + gap: var(--space-s); + margin-top: var(--space-s); +} + +.ap-notification__reply-btn, +.ap-notification__thread-btn { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-offset); + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-s); + text-decoration: none; + transition: all 0.2s ease; +} + +.ap-notification__reply-btn:hover, +.ap-notification__thread-btn:hover { + background: var(--color-offset-variant); + border-color: var(--color-outline-variant); + color: var(--color-on-background); +} + +.ap-notification__handle { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin-left: var(--space-xs); +} + +.ap-notifications__btn--primary { + background: var(--color-primary); + color: var(--color-on-primary, #fff); + text-decoration: none; +} + +.ap-notifications__btn--primary:hover { + opacity: 0.9; +} diff --git a/assets/css/profile.css b/assets/css/profile.css new file mode 100644 index 0000000..e71b9b6 --- /dev/null +++ b/assets/css/profile.css @@ -0,0 +1,308 @@ +/* ========================================================================== + Remote Profile + ========================================================================== */ + +.ap-profile__header { + border-radius: var(--border-radius-small); + height: 200px; + margin-bottom: var(--space-m); + overflow: hidden; +} + +.ap-profile__header-img { + height: 100%; + object-fit: cover; + width: 100%; +} + +.ap-profile__info { + margin-bottom: var(--space-l); +} + +.ap-profile__avatar-wrap { + height: 80px; + margin-bottom: var(--space-s); + position: relative; + width: 80px; +} + +.ap-profile__avatar-wrap > img { + position: absolute; + inset: 0; + z-index: 1; +} + +.ap-profile__avatar { + border: var(--border-width-thickest) solid var(--color-background); + border-radius: 50%; + height: 80px; + object-fit: cover; + width: 80px; +} + +.ap-profile__avatar--placeholder { + align-items: center; + background: var(--color-offset-variant); + color: var(--color-on-offset); + display: flex; + font-size: 2em; + font-weight: 600; + justify-content: center; +} + +.ap-profile__name { + font-size: var(--font-size-xl); + margin-bottom: var(--space-xs); +} + +.ap-profile__handle { + color: var(--color-on-offset); + margin-bottom: var(--space-s); +} + +.ap-profile__bio { + line-height: var(--line-height-prose); + margin-bottom: var(--space-s); +} + +.ap-profile__bio a { + color: var(--color-primary-on-background); +} + +/* Override upstream .mention { display: grid } for bio content */ +.ap-profile__bio .h-card { + display: inline; +} + +.ap-profile__bio .h-card a, +.ap-profile__bio a.u-url.mention { + display: inline; + white-space: nowrap; +} + +.ap-profile__bio .h-card a span, +.ap-profile__bio a.u-url.mention span { + display: inline; +} + +.ap-profile__bio a.mention.hashtag { + display: inline; + white-space: nowrap; +} + +.ap-profile__bio a.mention.hashtag span { + display: inline; +} + +/* Mastodon invisible/ellipsis spans for long URLs in bios */ +.ap-profile__bio .invisible { + display: none; +} + +.ap-profile__bio .ellipsis::after { + content: "…"; +} + +.ap-profile__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-s); + margin-top: var(--space-m); +} + +.ap-profile__action { + background: transparent; + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-background); + cursor: pointer; + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-m); + text-decoration: none; +} + +.ap-profile__action:hover { + background: var(--color-offset); +} + +.ap-profile__action--follow.ap-profile__action--active { + background: var(--color-primary); + border-color: var(--color-primary); + color: var(--color-on-primary, var(--color-neutral99)); +} + +.ap-profile__action--danger:hover { + border-color: var(--color-error); + color: var(--color-error); +} + +.ap-profile__posts { + margin-top: var(--space-l); +} + +.ap-profile__posts h3 { + border-bottom: var(--border-width-thin) solid var(--color-outline); + font-size: var(--font-size-l); + margin-bottom: var(--space-m); + padding-bottom: var(--space-s); +} + +/* ========================================================================== + My Profile — Admin Profile Header + ========================================================================== */ + +.ap-my-profile { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + margin-bottom: var(--space-m); + overflow: hidden; +} + +.ap-my-profile__header { + height: 160px; + overflow: hidden; +} + +.ap-my-profile__header-img { + height: 100%; + object-fit: cover; + width: 100%; +} + +.ap-my-profile__info { + padding: var(--space-m); +} + +.ap-my-profile__avatar-wrap { + margin-bottom: var(--space-s); + margin-top: -40px; +} + +.ap-my-profile__avatar { + border: 3px solid var(--color-background); + border-radius: 50%; + height: 72px; + object-fit: cover; + width: 72px; +} + +.ap-my-profile__avatar--placeholder { + align-items: center; + background: var(--color-offset-variant); + color: var(--color-on-offset); + display: flex; + font-size: 1.8em; + font-weight: 600; + justify-content: center; +} + +.ap-my-profile__name { + font-size: var(--font-size-xl); + margin-bottom: 0; +} + +.ap-my-profile__handle { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin-bottom: var(--space-s); +} + +.ap-my-profile__bio { + line-height: var(--line-height-prose); + margin-bottom: var(--space-s); +} + +.ap-my-profile__bio a { + color: var(--color-primary-on-background); +} + +/* Override upstream .mention { display: grid } for bio content */ +.ap-my-profile__bio .h-card { display: inline; } +.ap-my-profile__bio .h-card a, +.ap-my-profile__bio a.u-url.mention { display: inline; white-space: nowrap; } +.ap-my-profile__bio .h-card a span, +.ap-my-profile__bio a.u-url.mention span { display: inline; } +.ap-my-profile__bio a.mention.hashtag { display: inline; white-space: nowrap; } +.ap-my-profile__bio a.mention.hashtag span { display: inline; } +.ap-my-profile__bio .invisible { display: none; } +.ap-my-profile__bio .ellipsis::after { content: "…"; } + +.ap-my-profile__fields { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + margin: var(--space-s) 0; + overflow: hidden; +} + +.ap-my-profile__field { + border-bottom: var(--border-width-thin) solid var(--color-outline); + display: grid; + grid-template-columns: 120px 1fr; +} + +.ap-my-profile__field:last-child { + border-bottom: 0; +} + +.ap-my-profile__field-name { + background: var(--color-offset); + color: var(--color-on-offset); + font-size: var(--font-size-s); + font-weight: 600; + padding: var(--space-xs) var(--space-s); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.ap-my-profile__field-value { + font-size: var(--font-size-s); + overflow: hidden; + padding: var(--space-xs) var(--space-s); + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-my-profile__field-value a { + color: var(--color-primary-on-background); +} + +.ap-my-profile__stats { + display: flex; + gap: var(--space-m); + margin-bottom: var(--space-s); +} + +.ap-my-profile__stat { + color: var(--color-on-offset); + font-size: var(--font-size-s); + text-decoration: none; +} + +.ap-my-profile__stat:hover { + color: var(--color-on-background); +} + +.ap-my-profile__stat strong { + color: var(--color-on-background); + font-weight: 600; +} + +.ap-my-profile__edit { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-background); + display: inline-block; + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-m); + text-decoration: none; +} + +.ap-my-profile__edit:hover { + background: var(--color-offset); + border-color: var(--color-outline-variant); +} + +/* When no header image, don't offset avatar */ +.ap-my-profile__info:first-child .ap-my-profile__avatar-wrap { + margin-top: 0; +} diff --git a/assets/css/responsive.css b/assets/css/responsive.css new file mode 100644 index 0000000..3a0d434 --- /dev/null +++ b/assets/css/responsive.css @@ -0,0 +1,33 @@ +/* ========================================================================== + Responsive + ========================================================================== */ + +@media (max-width: 640px) { + .ap-tabs { + gap: 0; + } + + .ap-tab { + padding: var(--space-xs) var(--space-s); + } + + .ap-card__gallery--3 { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + } + + .ap-card__gallery--3 img:first-child { + grid-column: 1 / 3; + grid-row: 1; + height: 200px; + } + + .ap-card__actions { + gap: var(--space-xs); + } + + .ap-card__action { + font-size: 0.75rem; + padding: var(--space-xs); + } +} diff --git a/assets/css/skeleton.css b/assets/css/skeleton.css new file mode 100644 index 0000000..2a10d66 --- /dev/null +++ b/assets/css/skeleton.css @@ -0,0 +1,74 @@ +/* ========================================================================== + Skeleton Loaders + ========================================================================== */ + +@keyframes ap-skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.ap-skeleton { + background: linear-gradient(90deg, + var(--color-offset) 25%, + var(--color-background) 50%, + var(--color-offset) 75%); + background-size: 200% 100%; + animation: ap-skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: var(--border-radius-small); +} + +.ap-card--skeleton { + pointer-events: none; +} + +.ap-card--skeleton .ap-card__author { + display: flex; + align-items: center; + gap: var(--space-s); +} + +.ap-skeleton--avatar { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + flex-shrink: 0; +} + +.ap-skeleton-lines { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.ap-skeleton--name { + height: 0.85rem; + width: 40%; +} + +.ap-skeleton--handle { + height: 0.7rem; + width: 25%; +} + +.ap-skeleton-body { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: var(--space-s); +} + +.ap-skeleton--line { + height: 0.75rem; + width: 100%; +} + +.ap-skeleton--short { + width: 60%; +} + +.ap-skeleton-group { + display: flex; + flex-direction: column; + gap: var(--space-m); +} diff --git a/assets/reader-interactions.js b/assets/reader-interactions.js new file mode 100644 index 0000000..cf28238 --- /dev/null +++ b/assets/reader-interactions.js @@ -0,0 +1,115 @@ +/** + * Card interaction Alpine.js component. + * Handles like, boost, and save-for-later actions with optimistic UI and + * rollback on failure. + * + * Configured via data-* attributes on the container element (the