diff --git a/88x31/index.html b/88x31/index.html index 26855c3..c4b44aa 100644 --- a/88x31/index.html +++ b/88x31/index.html @@ -10,7 +10,8 @@ - + @@ -28,7 +29,8 @@ - + @@ -37,7 +39,8 @@ - + @@ -73,48 +76,69 @@
- Doughmination - Git Gay - Made with Visual Studio Code + Doughmination + Git Gay + Made with Visual Studio Code I dream in HTML - Valid HTML5 - Valid CSS - Keep the web free + Valid HTML5 + Valid CSS + Keep the web free No NFTs, no thanks No WEBp - Made on GNU/Linux - Firefox + Made on GNU/Linux + Firefox Anything but Chrome - Made on a Mac - WinRAR - Stop Microsoft + Made on a Mac + WinRAR + Stop Microsoft Made for dark mode - Powered by estrogen + Powered by estrogen Trans rights now Queer pride Girls Now Let boys wear skirts I wear cute socks! - No fascism, no bigotry - Melt ICE - Free Palestine - Slava Ukraini + No fascism, no bigotry + Melt ICE + Free Palestine + Slava Ukraini Discord Best viewed on desktop Kill me now - Don't click here, no! - Minecraft + Don't click here, no! + Minecraft Pokémon - Caramelldansen + Caramelldansen Anime blink - This site is Miku approved + This site is Miku approved Anime tummy supporter - Bad Apple!! + Bad Apple!!
@@ -123,4 +147,4 @@ - + \ No newline at end of file diff --git a/cool-people/index.html b/cool-people/index.html index 7aae02b..1002f22 100644 --- a/cool-people/index.html +++ b/cool-people/index.html @@ -23,7 +23,8 @@ - + @@ -41,7 +42,8 @@ - + @@ -50,19 +52,8 @@ - - - + @@ -89,26 +80,23 @@ -
+

Cool People

This is where people I know can be put up on my site, click their profiles for their pages

- -
+

Presence data is served by Doughmination Restful.

-
+ - - - + \ No newline at end of file diff --git a/css/fonts.css b/css/fonts.css index dced325..d710047 100644 --- a/css/fonts.css +++ b/css/fonts.css @@ -41,36 +41,43 @@ src: url('/assets/fonts/8Bit.woff2') format('woff2'); font-display: swap; } + @font-face { font-family: 'DDN Jellybean'; src: url('/assets/fonts/Jellybean.woff2') format('woff2'); font-display: swap; } + @font-face { font-family: 'DDN Medieval'; src: url('/assets/fonts/Medieval.woff2') format('woff2'); font-display: swap; } + @font-face { font-family: 'DDN Modern'; src: url('/assets/fonts/Modern.woff2') format('woff2'); font-display: swap; } + @font-face { font-family: 'DDN Sakura'; src: url('/assets/fonts/Sakura.woff2') format('woff2'); font-display: swap; } + @font-face { font-family: 'DDN Tempo'; src: url('/assets/fonts/Tempo.woff2') format('woff2'); font-display: swap; } + @font-face { font-family: 'DDN Vampyre'; src: url('/assets/fonts/Vampyre.woff2') format('woff2'); font-display: swap; } + @font-face { font-family: 'DDN gg sans'; src: url('/assets/fonts/gg%20sans.woff2') format('woff2'); diff --git a/css/main.css b/css/main.css index 9f0ae42..466c9b2 100644 --- a/css/main.css +++ b/css/main.css @@ -83,19 +83,29 @@ body { /* clickable */ -a, button, [role="button"], [role="link"], [data-href], label[for], select, summary, -.pc-name--link, #oneko { +a, +button, +[role="button"], +[role="link"], +[data-href], +label[for], +select, +summary, +.pc-name--link, +#oneko { cursor: url('../assets/cursor/pointer_0.png'), pointer; } /* text entry / editing */ input:not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]), -textarea, [contenteditable="true"] { +textarea, +[contenteditable="true"] { cursor: url('../assets/cursor/text_0.png'), text; } /* loading */ -[aria-busy="true"], .is-loading { +[aria-busy="true"], +.is-loading { cursor: url('../assets/cursor/wait_0.png'), wait; } @@ -105,12 +115,15 @@ textarea, [contenteditable="true"] { } /* help */ -[title]:not(a):not(button), .help { +[title]:not(a):not(button), +.help { cursor: url('../assets/cursor/help_0.png'), help; } /* disabled */ -:disabled, [disabled], [aria-disabled="true"] { +:disabled, +[disabled], +[aria-disabled="true"] { cursor: url('../assets/cursor/not-allowed_0.png'), not-allowed; } @@ -120,9 +133,17 @@ textarea, [contenteditable="true"] { } /* crosshair / zoom (if you use them) */ -.crosshair { cursor: url('../assets/cursor/crosshair_0.png'), crosshair; } -.zoom-in { cursor: url('../assets/cursor/zoom-in_0.png'), zoom-in; } -.zoom-out { cursor: url('../assets/cursor/zoom-out_0.png'), zoom-out; } +.crosshair { + cursor: url('../assets/cursor/crosshair_0.png'), crosshair; +} + +.zoom-in { + cursor: url('../assets/cursor/zoom-in_0.png'), zoom-in; +} + +.zoom-out { + cursor: url('../assets/cursor/zoom-out_0.png'), zoom-out; +} /* Estrogen watermark blended into the background */ body::before { @@ -1598,7 +1619,8 @@ body:has(.terminal) .hub .terminal { .t-main { display: flex; flex-direction: column; - flex: 1; /* fill the fixed-height terminal so .t-output scrolls */ + flex: 1; + /* fill the fixed-height terminal so .t-output scrolls */ min-height: 0; } @@ -1967,12 +1989,15 @@ body:has(.friends-wrap) { min-height: 100dvh; overflow-y: auto; } + body:has(.friends-wrap) { align-items: flex-start; } + body:has(.friends-wrap) .hub { max-width: 960px; } + #friends-root { display: flex; flex-direction: column; @@ -1992,6 +2017,7 @@ body:has(.friends-wrap) .hub { overflow: hidden; transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; } + .friend-card:hover { transform: translateY(-3px); border-color: rgb(var(--accent-rgb)); @@ -2013,24 +2039,32 @@ body:has(.friends-wrap) .hub { card's flow height can be pinned BEFORE .fc-main is taken out of flow — otherwise the card collapses, the page reflows, and hover ping-pongs (the "vibrating page" bug). */ -.friend-card.is-hovering { overflow: visible; z-index: 5; } +.friend-card.is-hovering { + overflow: visible; + z-index: 5; +} + .friend-card.is-hovering .fc-main { position: absolute; left: 0; right: 0; - top: 54px; /* sit directly under the banner strip */ - background: inherit; /* carry the card surface / Nitro gradient */ + top: 54px; + /* sit directly under the banner strip */ + background: inherit; + /* carry the card surface / Nitro gradient */ border-bottom-left-radius: 14px; border-bottom-right-radius: 14px; box-shadow: 0 10px 24px -14px rgba(17, 17, 27, 0.8); } + .friend-card.is-hovering .fc-name, .friend-card.is-hovering .fc-user, .friend-card.is-hovering .fc-custom-text { white-space: normal; overflow: visible; text-overflow: clip; - overflow-wrap: anywhere; /* break long unbroken usernames / handles */ + overflow-wrap: anywhere; + /* break long unbroken usernames / handles */ } /* banner strip — Nitro banner image / accent colour / default wash */ @@ -2045,6 +2079,7 @@ body:has(.friends-wrap) .hub { border-top-left-radius: 14px; border-top-right-radius: 14px; } + .friend-card.has-banner .fc-banner { background-size: cover; background-position: center; @@ -2064,6 +2099,7 @@ body:has(.friends-wrap) .hub { height: 56px; margin-top: -30px; } + .fc-pfp { width: 56px; height: 56px; @@ -2084,7 +2120,10 @@ body:has(.friends-wrap) .hub { transform: translate(-50%, -50%); pointer-events: none; } -.fc-deco[hidden] { display: none; } + +.fc-deco[hidden] { + display: none; +} /* status dot — colour driven by data-status on the card */ .fc-status { @@ -2097,11 +2136,26 @@ body:has(.friends-wrap) .hub { border: 3px solid var(--surface-0); background: var(--blue); } -.friend-card[data-status="online"] .fc-status { background: var(--green); } -.friend-card[data-status="idle"] .fc-status { background: var(--yellow); } -.friend-card[data-status="dnd"] .fc-status { background: var(--red); } -.friend-card[data-status="offline"] .fc-status { background: var(--overlay-0); } -.friend-card[data-status="unconnected"] .fc-status { background: var(--blue); } + +.friend-card[data-status="online"] .fc-status { + background: var(--green); +} + +.friend-card[data-status="idle"] .fc-status { + background: var(--yellow); +} + +.friend-card[data-status="dnd"] .fc-status { + background: var(--red); +} + +.friend-card[data-status="offline"] .fc-status { + background: var(--overlay-0); +} + +.friend-card[data-status="unconnected"] .fc-status { + background: var(--blue); +} .fc-id { display: flex; @@ -2118,9 +2172,13 @@ body:has(.friends-wrap) .hub { gap: 0.15rem 0.3rem; min-width: 0; } + /* name claims the full first line (so a clan tag never truncates it); the tag chip wraps onto its own line just beneath when there isn't room */ -.fc-name-row .fc-name { flex: 1 1 100%; min-width: 0; } +.fc-name-row .fc-name { + flex: 1 1 100%; + min-width: 0; +} /* Discord server (clan) tag chip */ .fc-tag { @@ -2136,8 +2194,16 @@ body:has(.friends-wrap) .hub { letter-spacing: 0.02em; color: var(--text); } -.fc-tag[hidden] { display: none; } -.fc-tag-badge { width: 12px; height: 12px; display: block; } + +.fc-tag[hidden] { + display: none; +} + +.fc-tag-badge { + width: 12px; + height: 12px; + display: block; +} .fc-name { font-size: 0.95rem; @@ -2148,7 +2214,10 @@ body:has(.friends-wrap) .hub { overflow: hidden; text-overflow: ellipsis; } -a.fc-name:hover { color: rgb(var(--accent-rgb)); } + +a.fc-name:hover { + color: rgb(var(--accent-rgb)); +} /* Nitro display-name gradient (clipped to the text). Keep the tier heart in ::before painted normally so it stays its solid colour. */ @@ -2158,13 +2227,28 @@ a.fc-name:hover { color: rgb(var(--accent-rgb)); } color: transparent; -webkit-text-fill-color: transparent; } -.fc-name.is-gradient::before { -webkit-text-fill-color: initial; color: var(--text); } + +.fc-name.is-gradient::before { + -webkit-text-fill-color: initial; + color: var(--text); +} /* tier hearts (match the old friend-name prefixes) */ -.fc-name::before { content: "🩵 "; } -.fc-name.known::before { content: "💛 "; } -.fc-name.wife::before { content: "🖤 "; } -.fc-name.close::before { content: "🤍 "; } +.fc-name::before { + content: "🩵 "; +} + +.fc-name.known::before { + content: "💛 "; +} + +.fc-name.wife::before { + content: "🖤 "; +} + +.fc-name.close::before { + content: "🤍 "; +} .fc-user { font-size: 0.72rem; @@ -2173,7 +2257,10 @@ a.fc-name:hover { color: rgb(var(--accent-rgb)); } overflow: hidden; text-overflow: ellipsis; } -.fc-user:empty { display: none; } + +.fc-user:empty { + display: none; +} /* custom status (Discord activity type 4) */ .fc-custom { @@ -2192,6 +2279,7 @@ a.fc-name:hover { color: rgb(var(--accent-rgb)); } border-radius: 11px; border-top-left-radius: 4px; } + /* Discord-style thought-bubble tail: two little circles rising to the name */ .fc-custom::before, .fc-custom::after { @@ -2201,11 +2289,38 @@ a.fc-name:hover { color: rgb(var(--accent-rgb)); } border-radius: 50%; pointer-events: none; } -.fc-custom::before { width: 7px; height: 7px; top: -4px; left: 9px; } -.fc-custom::after { width: 4px; height: 4px; top: -8px; left: 7px; } -.fc-custom[hidden] { display: none; } -.fc-custom-emoji { width: 14px; height: 14px; display: block; flex: none; } -.fc-custom-emoji-uni { font-size: 0.8rem; line-height: 1; flex: none; } + +.fc-custom::before { + width: 7px; + height: 7px; + top: -4px; + left: 9px; +} + +.fc-custom::after { + width: 4px; + height: 4px; + top: -8px; + left: 7px; +} + +.fc-custom[hidden] { + display: none; +} + +.fc-custom-emoji { + width: 14px; + height: 14px; + display: block; + flex: none; +} + +.fc-custom-emoji-uni { + font-size: 0.8rem; + line-height: 1; + flex: none; +} + .fc-custom-text { white-space: nowrap; overflow: hidden; @@ -2219,23 +2334,44 @@ a.fc-name:hover { color: rgb(var(--accent-rgb)); } gap: 0.2rem; margin-top: 0.1rem; } -.fc-badges:empty { display: none; } -.fc-badge { width: 16px; height: 16px; display: block; } -.fc-badge-link { display: inline-flex; line-height: 0; } + +.fc-badges:empty { + display: none; +} + +.fc-badge { + width: 16px; + height: 16px; + display: block; +} + +.fc-badge-link { + display: inline-flex; + line-height: 0; +} /* ---- Alts (active + dead) ---- */ -.fc-name.active-alt::before { content: "🎭 "; } -.fc-name.dead-alt::before { content: "💀 "; } +.fc-name.active-alt::before { + content: "🎭 "; +} + +.fc-name.dead-alt::before { + content: "💀 "; +} /* dead alts: banned accounts — greyed out, name struck through, no status */ .friend-card.tier-dead-alt .fc-pfp { filter: grayscale(1) brightness(0.6); } + .friend-card.tier-dead-alt .fc-name { color: var(--overlay-1); text-decoration: line-through; } -.friend-card.tier-dead-alt .fc-status { display: none; } + +.friend-card.tier-dead-alt .fc-status { + display: none; +} /* the "struck off" diagonal slash across the avatar */ .friend-card.tier-dead-alt .fc-avatar::after { @@ -2849,54 +2985,126 @@ body.term-chrome-in #visitor-counter { ============================================================ */ html[data-theme="mocha"] { color-scheme: dark; - --rosewater: #f5e0dc; --flamingo: #f2cdcd; --pink: #f5c2e7; + --rosewater: #f5e0dc; + --flamingo: #f2cdcd; + --pink: #f5c2e7; --accent-rgb: 245, 194, 231; - --mauve: #cba6f7; --red: #f38ba8; --maroon: #eba0ac; --peach: #fab387; - --yellow: #f9e2af; --green: #a6e3a1; --teal: #94e2d5; --sky: #89dceb; - --saphire: #74c7ec; --blue: #89b4fa; --lavender: #b4befe; - --text: #cdd6f4; --subtext-0: #a6adc8; --subtext-1: #bac2de; - --overlay-0: #6c7086; --overlay-1: #7f849c; --overlay-2: #9399b2; - --surface-0: #313244; --surface-1: #45475a; --surface-2: #585b70; - --base: #1e1e2e; --mantle: #181825; --crust: #11111b; + --mauve: #cba6f7; + --red: #f38ba8; + --maroon: #eba0ac; + --peach: #fab387; + --yellow: #f9e2af; + --green: #a6e3a1; + --teal: #94e2d5; + --sky: #89dceb; + --saphire: #74c7ec; + --blue: #89b4fa; + --lavender: #b4befe; + --text: #cdd6f4; + --subtext-0: #a6adc8; + --subtext-1: #bac2de; + --overlay-0: #6c7086; + --overlay-1: #7f849c; + --overlay-2: #9399b2; + --surface-0: #313244; + --surface-1: #45475a; + --surface-2: #585b70; + --base: #1e1e2e; + --mantle: #181825; + --crust: #11111b; } html[data-theme="macchiato"] { color-scheme: dark; - --rosewater: #f4dbd6; --flamingo: #f0c6c6; --pink: #f5bde6; + --rosewater: #f4dbd6; + --flamingo: #f0c6c6; + --pink: #f5bde6; --accent-rgb: 245, 189, 230; - --mauve: #c6a0f6; --red: #ed8796; --maroon: #ee99a0; --peach: #f5a97f; - --yellow: #eed49f; --green: #a6da95; --teal: #8bd5ca; --sky: #91d7e3; - --saphire: #7dc4e4; --blue: #8aadf4; --lavender: #b7bdf8; - --text: #cad3f5; --subtext-0: #a5adcb; --subtext-1: #b8c0e0; - --overlay-0: #6e738d; --overlay-1: #8087a2; --overlay-2: #939ab7; - --surface-0: #363a4f; --surface-1: #494d64; --surface-2: #5b6078; - --base: #24273a; --mantle: #1e2030; --crust: #181926; + --mauve: #c6a0f6; + --red: #ed8796; + --maroon: #ee99a0; + --peach: #f5a97f; + --yellow: #eed49f; + --green: #a6da95; + --teal: #8bd5ca; + --sky: #91d7e3; + --saphire: #7dc4e4; + --blue: #8aadf4; + --lavender: #b7bdf8; + --text: #cad3f5; + --subtext-0: #a5adcb; + --subtext-1: #b8c0e0; + --overlay-0: #6e738d; + --overlay-1: #8087a2; + --overlay-2: #939ab7; + --surface-0: #363a4f; + --surface-1: #494d64; + --surface-2: #5b6078; + --base: #24273a; + --mantle: #1e2030; + --crust: #181926; } html[data-theme="frappe"] { color-scheme: dark; - --rosewater: #f2d5cf; --flamingo: #eebebe; --pink: #f4b8e4; + --rosewater: #f2d5cf; + --flamingo: #eebebe; + --pink: #f4b8e4; --accent-rgb: 244, 184, 228; - --mauve: #ca9ee6; --red: #e78284; --maroon: #ea999c; --peach: #ef9f76; - --yellow: #e5c890; --green: #a6d189; --teal: #81c8be; --sky: #99d1db; - --saphire: #85c1dc; --blue: #8caaee; --lavender: #babbf1; - --text: #c6d0f5; --subtext-0: #a5adce; --subtext-1: #b5bfe2; - --overlay-0: #737994; --overlay-1: #838ba7; --overlay-2: #949cbb; - --surface-0: #414559; --surface-1: #51576d; --surface-2: #626880; - --base: #303446; --mantle: #292c3c; --crust: #232634; + --mauve: #ca9ee6; + --red: #e78284; + --maroon: #ea999c; + --peach: #ef9f76; + --yellow: #e5c890; + --green: #a6d189; + --teal: #81c8be; + --sky: #99d1db; + --saphire: #85c1dc; + --blue: #8caaee; + --lavender: #babbf1; + --text: #c6d0f5; + --subtext-0: #a5adce; + --subtext-1: #b5bfe2; + --overlay-0: #737994; + --overlay-1: #838ba7; + --overlay-2: #949cbb; + --surface-0: #414559; + --surface-1: #51576d; + --surface-2: #626880; + --base: #303446; + --mantle: #292c3c; + --crust: #232634; } html[data-theme="latte"] { color-scheme: light; - --rosewater: #dc8a78; --flamingo: #dd7878; --pink: #ea76cb; + --rosewater: #dc8a78; + --flamingo: #dd7878; + --pink: #ea76cb; --accent-rgb: 234, 118, 203; - --mauve: #8839ef; --red: #d20f39; --maroon: #e64553; --peach: #fe640b; - --yellow: #df8e1d; --green: #40a02b; --teal: #179299; --sky: #04a5e5; - --saphire: #209fb5; --blue: #1e66f5; --lavender: #7287fd; - --text: #4c4f69; --subtext-0: #6c6f85; --subtext-1: #5c5f77; - --overlay-0: #9ca0b0; --overlay-1: #8c8fa1; --overlay-2: #7c7f93; - --surface-0: #ccd0da; --surface-1: #bcc0cc; --surface-2: #acb0be; - --base: #eff1f5; --mantle: #e6e9ef; --crust: #dce0e8; + --mauve: #8839ef; + --red: #d20f39; + --maroon: #e64553; + --peach: #fe640b; + --yellow: #df8e1d; + --green: #40a02b; + --teal: #179299; + --sky: #04a5e5; + --saphire: #209fb5; + --blue: #1e66f5; + --lavender: #7287fd; + --text: #4c4f69; + --subtext-0: #6c6f85; + --subtext-1: #5c5f77; + --overlay-0: #9ca0b0; + --overlay-1: #8c8fa1; + --overlay-2: #7c7f93; + --surface-0: #ccd0da; + --surface-1: #bcc0cc; + --surface-2: #acb0be; + --base: #eff1f5; + --mantle: #e6e9ef; + --crust: #dce0e8; } @@ -2937,7 +3145,10 @@ html[data-theme] body.api-body { max-width: 420px; line-height: 1.5; } -.api-empty a { color: rgb(var(--accent-rgb)); } + +.api-empty a { + color: rgb(var(--accent-rgb)); +} /* ===================================================================== @@ -2960,7 +3171,9 @@ html[data-theme] body.api-body { transition: border-color 0.2s ease, box-shadow 0.2s ease; } -.presence-card[hidden] { display: none; } +.presence-card[hidden] { + display: none; +} .presence-card.has-accent { border-color: rgba(var(--dc-accent), 0.5); @@ -3003,7 +3216,10 @@ html[data-theme] body.api-body { transform: translate(-50%, -50%); pointer-events: none; } -.pc-av-deco[hidden] { display: none; } + +.pc-av-deco[hidden] { + display: none; +} .pc-status { position: absolute; @@ -3015,10 +3231,22 @@ html[data-theme] body.api-body { border: 2.5px solid var(--surface-0); background: var(--overlay-0); } -.presence-card[data-status="online"] .pc-status { background: var(--green); } -.presence-card[data-status="idle"] .pc-status { background: var(--yellow); } -.presence-card[data-status="dnd"] .pc-status { background: var(--red); } -.presence-card[data-status="offline"] .pc-status { background: var(--overlay-0); } + +.presence-card[data-status="online"] .pc-status { + background: var(--green); +} + +.presence-card[data-status="idle"] .pc-status { + background: var(--yellow); +} + +.presence-card[data-status="dnd"] .pc-status { + background: var(--red); +} + +.presence-card[data-status="offline"] .pc-status { + background: var(--overlay-0); +} .pc-id { display: flex; @@ -3026,6 +3254,7 @@ html[data-theme] body.api-body { gap: 0.05rem; min-width: 0; } + .pc-name { font-size: 0.92rem; font-weight: 700; @@ -3035,12 +3264,16 @@ html[data-theme] body.api-body { text-overflow: ellipsis; transition: color 0.5s ease; } + .pc-user { font-size: 0.7rem; color: var(--subtext-0); white-space: nowrap; } -.pc-user:empty { display: none; } + +.pc-user:empty { + display: none; +} /* status word (Online / Idle / Do Not Disturb / Offline) */ .pc-status-text { @@ -3049,7 +3282,11 @@ html[data-theme] body.api-body { white-space: nowrap; color: var(--overlay-1); } -.pc-status-text:empty { display: none; } + +.pc-status-text:empty { + display: none; +} + .pc-status-text::before { content: ""; display: inline-block; @@ -3060,14 +3297,38 @@ html[data-theme] body.api-body { vertical-align: baseline; background: var(--overlay-0); } -.presence-card[data-status="online"] .pc-status-text { color: var(--green); } -.presence-card[data-status="online"] .pc-status-text::before { background: var(--green); } -.presence-card[data-status="idle"] .pc-status-text { color: var(--yellow); } -.presence-card[data-status="idle"] .pc-status-text::before { background: var(--yellow); } -.presence-card[data-status="dnd"] .pc-status-text { color: var(--red); } -.presence-card[data-status="dnd"] .pc-status-text::before { background: var(--red); } -.presence-card[data-status="offline"] .pc-status-text { color: var(--overlay-1); } -.presence-card[data-status="offline"] .pc-status-text::before { background: var(--overlay-0); } + +.presence-card[data-status="online"] .pc-status-text { + color: var(--green); +} + +.presence-card[data-status="online"] .pc-status-text::before { + background: var(--green); +} + +.presence-card[data-status="idle"] .pc-status-text { + color: var(--yellow); +} + +.presence-card[data-status="idle"] .pc-status-text::before { + background: var(--yellow); +} + +.presence-card[data-status="dnd"] .pc-status-text { + color: var(--red); +} + +.presence-card[data-status="dnd"] .pc-status-text::before { + background: var(--red); +} + +.presence-card[data-status="offline"] .pc-status-text { + color: var(--overlay-1); +} + +.presence-card[data-status="offline"] .pc-status-text::before { + background: var(--overlay-0); +} /* ---- expandable sections ---- */ .pc-sections { @@ -3077,7 +3338,10 @@ html[data-theme] body.api-body { padding: 0 0.6rem 0.6rem; transition: opacity 0.2s ease; } -.presence-card:not(.has-sections) .pc-sections { display: none; } + +.presence-card:not(.has-sections) .pc-sections { + display: none; +} .pc-row { display: flex; @@ -3091,6 +3355,7 @@ html[data-theme] body.api-body { text-decoration: none; transition: border-color 0.15s ease, transform 0.15s ease; } + a.pc-row:hover, .pc-row--stack:hover { border-color: rgba(var(--dc-accent), 0.55); @@ -3103,12 +3368,14 @@ a.pc-row:hover, gap: 0.04rem; min-width: 0; } + .pc-row-kind { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--subtext-0); } + .pc-row-title { font-size: 0.8rem; font-weight: 500; @@ -3117,6 +3384,7 @@ a.pc-row:hover, text-overflow: ellipsis; max-width: 200px; } + .pc-row-sub { font-size: 0.7rem; color: var(--subtext-0); @@ -3125,14 +3393,21 @@ a.pc-row:hover, text-overflow: ellipsis; max-width: 200px; } -.pc-row-title:empty, .pc-row-sub:empty { display: none; } + +.pc-row-title:empty, +.pc-row-sub:empty { + display: none; +} .pc-row-elapsed { font-size: 0.62rem; color: var(--subtext-0); margin-top: 0.1rem; } -.pc-row-elapsed:empty { display: none; } + +.pc-row-elapsed:empty { + display: none; +} /* artwork / icons */ .pc-art, @@ -3143,6 +3418,7 @@ a.pc-row:hover, object-fit: cover; flex-shrink: 0; } + .pc-row-ic.pc-dot { width: 9px; height: 9px; @@ -3151,9 +3427,19 @@ a.pc-row:hover, margin: 0 0.5rem; background: rgb(var(--accent-rgb)); } -.pc-dev .pc-row-ic.pc-dot { background: var(--blue); border-radius: 2px; } -.pc-game .pc-row-ic.pc-dot { background: var(--green); } -.pc-stream .pc-row-ic.pc-dot{ background: var(--mauve); } + +.pc-dev .pc-row-ic.pc-dot { + background: var(--blue); + border-radius: 2px; +} + +.pc-game .pc-row-ic.pc-dot { + background: var(--green); +} + +.pc-stream .pc-row-ic.pc-dot { + background: var(--mauve); +} /* custom status */ .pc-custom { @@ -3168,6 +3454,7 @@ a.pc-row:hover, border-top-left-radius: 4px; margin-top: 0.3rem; } + /* Discord-style thought-bubble tail */ .pc-custom::before, .pc-custom::after { @@ -3177,10 +3464,33 @@ a.pc-row:hover, border-radius: 50%; pointer-events: none; } -.pc-custom::before { width: 9px; height: 9px; top: -5px; left: 12px; } -.pc-custom::after { width: 5px; height: 5px; top: -11px; left: 9px; } -.pc-custom:hover { transform: none; border-color: transparent; } -.pc-emoji { width: 18px; height: 18px; flex-shrink: 0; margin-top: 0.05rem; } + +.pc-custom::before { + width: 9px; + height: 9px; + top: -5px; + left: 12px; +} + +.pc-custom::after { + width: 5px; + height: 5px; + top: -11px; + left: 9px; +} + +.pc-custom:hover { + transform: none; + border-color: transparent; +} + +.pc-emoji { + width: 18px; + height: 18px; + flex-shrink: 0; + margin-top: 0.05rem; +} + .pc-custom-text { font-size: 0.74rem; color: var(--subtext-0); @@ -3191,7 +3501,10 @@ a.pc-row:hover, } /* spotify progress */ -.pc-spotify .pc-row-title { color: var(--green); } +.pc-spotify .pc-row-title { + color: var(--green); +} + .pc-progress { display: flex; flex-direction: column; @@ -3199,12 +3512,14 @@ a.pc-row:hover, margin-top: 0.25rem; width: 180px; } + .pc-bar { height: 4px; border-radius: 999px; background: var(--surface-1); overflow: hidden; } + .pc-fill { display: block; height: 100%; @@ -3212,6 +3527,7 @@ a.pc-row:hover, border-radius: 999px; background: rgb(var(--dc-accent)); } + .pc-times { display: flex; justify-content: space-between; @@ -3221,8 +3537,13 @@ a.pc-row:hover, } @media (max-width: 640px) { - .presence-card { max-width: calc(100vw - 2rem); } - .api-stage .presence-card { max-width: 100%; } + .presence-card { + max-width: calc(100vw - 2rem); + } + + .api-stage .presence-card { + max-width: 100%; + } } /* ---- extended Lanyard fields ---- */ @@ -3232,7 +3553,10 @@ a.pc-row:hover, gap: 0.35rem; min-width: 0; } -.pc-name-row .pc-name { min-width: 0; } + +.pc-name-row .pc-name { + min-width: 0; +} /* gradient display name (display_name_styles) */ .pc-name.is-gradient { @@ -3256,8 +3580,16 @@ a.pc-row:hover, letter-spacing: 0.03em; color: var(--text); } -.pc-tag[hidden] { display: none; } -.pc-tag-badge { width: 14px; height: 14px; display: block; } + +.pc-tag[hidden] { + display: none; +} + +.pc-tag-badge { + width: 14px; + height: 14px; + display: block; +} /* username + active-platform indicators */ .pc-sub-row { @@ -3265,14 +3597,25 @@ a.pc-row:hover, align-items: center; gap: 0.35rem; } + .pc-platforms { display: inline-flex; align-items: center; gap: 0.2rem; color: var(--subtext-0); } -.pc-plat { width: 12px; height: 12px; display: inline-flex; } -.pc-plat svg { width: 12px; height: 12px; display: block; } + +.pc-plat { + width: 12px; + height: 12px; + display: inline-flex; +} + +.pc-plat svg { + width: 12px; + height: 12px; + display: block; +} /* KV meta line (location, etc.) */ .pc-meta { @@ -3283,8 +3626,15 @@ a.pc-row:hover, font-size: 0.66rem; color: var(--subtext-0); } -.pc-meta[hidden] { display: none; } -.pc-pin { font-size: 0.7rem; line-height: 1; } + +.pc-meta[hidden] { + display: none; +} + +.pc-pin { + font-size: 0.7rem; + line-height: 1; +} /* stacked rows (activity rows that carry buttons) */ .pc-row--stack { @@ -3292,6 +3642,7 @@ a.pc-row:hover, align-items: stretch; gap: 0.4rem; } + .pc-row-link { display: flex; align-items: center; @@ -3308,7 +3659,12 @@ a.pc-row:hover, width: 38px; height: 38px; } -.pc-ic-wrap .pc-row-ic-img { width: 38px; height: 38px; } + +.pc-ic-wrap .pc-row-ic-img { + width: 38px; + height: 38px; +} + .pc-ic-badge { position: absolute; right: -3px; @@ -3326,6 +3682,7 @@ a.pc-row:hover, flex-wrap: wrap; gap: 0.35rem; } + .pc-btn { font-size: 0.66rem; padding: 0.22rem 0.55rem; @@ -3336,6 +3693,7 @@ a.pc-row:hover, border: 1px solid transparent; transition: border-color 0.15s ease, background 0.15s ease; } + .pc-btn:hover { border-color: rgb(var(--dc-accent)); background: var(--surface-2); @@ -3349,9 +3707,21 @@ a.pc-row:hover, flex-wrap: wrap; margin-top: 0.15rem; } -.pc-badges:empty { display: none; } -.pc-badge { width: 16px; height: 16px; display: block; } -.pc-badge-link { display: inline-flex; line-height: 0; } + +.pc-badges:empty { + display: none; +} + +.pc-badge { + width: 16px; + height: 16px; + display: block; +} + +.pc-badge-link { + display: inline-flex; + line-height: 0; +} /* wishlist star + panel */ .pc-star { @@ -3366,16 +3736,27 @@ a.pc-row:hover, padding: 0.1rem 0.15rem; transition: color 0.15s ease, transform 0.15s ease; } -.pc-star:hover { color: rgb(var(--accent-rgb)); transform: scale(1.12); } -.pc-star.on { color: var(--yellow); } -.pc-wishlist { display: none; } +.pc-star:hover { + color: rgb(var(--accent-rgb)); + transform: scale(1.12); +} + +.pc-star.on { + color: var(--yellow); +} + +.pc-wishlist { + display: none; +} + .presence-card.show-wishlist .pc-wishlist { display: block; border-top: 1px solid var(--surface-1); margin: 0 0.6rem; padding: 0.6rem 0 0.7rem; } + .pc-wishlist-title { font-size: 0.62rem; text-transform: uppercase; @@ -3383,6 +3764,7 @@ a.pc-row:hover, color: var(--subtext-0); margin-bottom: 0.45rem; } + .pc-wl-item { display: flex; align-items: center; @@ -3392,16 +3774,36 @@ a.pc-row:hover, text-decoration: none; color: var(--text); } -a.pc-wl-item:hover { background: var(--mantle); } -.pc-wl-ic { width: 22px; height: 22px; border-radius: 5px; object-fit: cover; } -.pc-wl-text { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; } -.pc-wl-name { font-size: 0.8rem; } + +a.pc-wl-item:hover { + background: var(--mantle); +} + +.pc-wl-ic { + width: 22px; + height: 22px; + border-radius: 5px; + object-fit: cover; +} + +.pc-wl-text { + display: flex; + flex-direction: column; + line-height: 1.2; + min-width: 0; +} + +.pc-wl-name { + font-size: 0.8rem; +} + .pc-wl-type { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--subtext-0); } + .pc-wl-price { margin-left: auto; padding-left: 0.5rem; @@ -3409,17 +3811,29 @@ a.pc-wl-item:hover { background: var(--mantle); } color: var(--subtext-1); white-space: nowrap; } -.pc-wl-item.is-owned { opacity: 0.5; } -.pc-wl-empty { font-size: 0.78rem; color: var(--subtext-0); margin: 0; } + +.pc-wl-item.is-owned { + opacity: 0.5; +} + +.pc-wl-empty { + font-size: 0.78rem; + color: var(--subtext-0); + margin: 0; +} /* Discord profile gradient (Catppuccin is the fallback) */ .presence-card.has-profile-grad { background: linear-gradient(180deg, rgb(var(--pc-grad-1-rgb)) 0%, rgb(var(--pc-grad-2-rgb)) 100%); } + .presence-card.has-profile-grad:not(.has-accent) { border-color: rgba(var(--pc-grad-1-rgb), 0.6); } -.presence-card.has-profile-grad .pc-row { background: rgba(17, 17, 27, 0.55); } + +.presence-card.has-profile-grad .pc-row { + background: rgba(17, 17, 27, 0.55); +} /* ===================================================================== @@ -3432,7 +3846,10 @@ a.pc-wl-item:hover { background: var(--mantle); } object-fit: cover; margin: 0; } -.pc-banner[hidden] { display: none; } + +.pc-banner[hidden] { + display: none; +} /* solid/accent banner fallback when there's no Nitro banner image */ .presence-card.has-banner-color::before { @@ -3447,17 +3864,20 @@ a.pc-wl-item:hover { background: var(--mantle); } .presence-card.has-banner-color .pc-head { margin-top: -22px; } + .presence-card.has-banner .pc-avatar, .presence-card.has-banner-color .pc-avatar { width: 56px; height: 56px; } + .presence-card.has-banner .pc-av-img, .presence-card.has-banner-color .pc-av-img { width: 56px; height: 56px; border: 3px solid var(--surface-0); } + .presence-card.has-banner .pc-av-deco, .presence-card.has-banner-color .pc-av-deco { width: 72px; @@ -3475,7 +3895,10 @@ a.pc-wl-item:hover { background: var(--mantle); } white-space: pre-wrap; overflow-wrap: anywhere; } -.pc-bio[hidden] { display: none; } + +.pc-bio[hidden] { + display: none; +} .pc-connections { display: flex; @@ -3483,7 +3906,10 @@ a.pc-wl-item:hover { background: var(--mantle); } gap: 0.35rem; margin: 0 0.7rem 0.6rem; } -.pc-connections[hidden] { display: none; } + +.pc-connections[hidden] { + display: none; +} .pc-conn { display: inline-flex; @@ -3498,17 +3924,23 @@ a.pc-wl-item:hover { background: var(--mantle); } text-decoration: none; transition: border-color 0.15s ease, background 0.15s ease; } + a.pc-conn:hover { border-color: rgb(var(--accent-rgb)); background: var(--surface-2); } + .pc-conn-type { text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.56rem; color: var(--subtext-0); } -.pc-conn-check { color: var(--green); font-weight: 700; } + +.pc-conn-check { + color: var(--green); + font-weight: 700; +} /* ===================================================================== @@ -3520,9 +3952,11 @@ body:has(.presence-stage) { min-height: 100dvh; overflow-y: auto; } + body:has(.presence-stage) { padding: 0; } + .presence-stage { min-height: 100dvh; display: flex; @@ -3531,15 +3965,18 @@ body:has(.presence-stage) { justify-content: center; padding: 1.5rem; } + .presence-intro { text-align: center; margin: 0 0 1.25rem; } + .presence-intro h1 { margin: 0; font-size: 1.8rem; color: rgb(var(--accent-rgb)); } + .presence-intro p { margin: 0.3rem 0 0; font-size: 0.9rem; @@ -3556,7 +3993,10 @@ body:has(.presence-stage) { color: var(--subtext-1); white-space: nowrap; } -.pc-pronouns[hidden] { display: none; } + +.pc-pronouns[hidden] { + display: none; +} /* ===================================================================== @@ -3574,6 +4014,7 @@ body:has(.presence-stage) { .presence-stage .pc-banner { height: 220px; } + .presence-stage .presence-card.has-banner-color::before { height: 150px; } @@ -3584,27 +4025,32 @@ body:has(.presence-stage) { padding: 0.9rem 1.4rem; align-items: flex-end; } + /* keep the identity column bottom-aligned to the avatar's visible lower half, whether or not a custom-status bubble is present */ .presence-stage .presence-card.has-custom .pc-head { align-items: flex-end; } + /* only the avatar pokes up into the banner; the name/identity block stays in the solid area below it */ .presence-stage .presence-card.has-banner .pc-head, .presence-stage .presence-card.has-banner-color .pc-head { margin-top: 0; } + .presence-stage .presence-card.has-banner .pc-avatar, .presence-stage .presence-card.has-banner-color .pc-avatar { margin-top: -60px; } + .presence-stage .pc-avatar, .presence-stage .presence-card.has-banner .pc-avatar, .presence-stage .presence-card.has-banner-color .pc-avatar { width: 120px; height: 120px; } + .presence-stage .pc-av-img, .presence-stage .presence-card.has-banner .pc-av-img, .presence-stage .presence-card.has-banner-color .pc-av-img { @@ -3615,12 +4061,14 @@ body:has(.presence-stage) { background: var(--crust); border: 6px solid var(--crust); } + .presence-stage .pc-av-deco, .presence-stage .presence-card.has-banner .pc-av-deco, .presence-stage .presence-card.has-banner-color .pc-av-deco { width: 152px; height: 152px; } + .presence-stage .pc-status { width: 24px; height: 24px; @@ -3628,10 +4076,22 @@ body:has(.presence-stage) { } /* identity block — bigger type */ -.presence-stage .pc-name { font-size: 1.5rem; } -.presence-stage .pc-user { font-size: 0.9rem; } -.presence-stage .pc-badges { margin-top: 0.3rem; } -.presence-stage .pc-badge { width: 22px; height: 22px; } +.presence-stage .pc-name { + font-size: 1.5rem; +} + +.presence-stage .pc-user { + font-size: 0.9rem; +} + +.presence-stage .pc-badges { + margin-top: 0.3rem; +} + +.presence-stage .pc-badge { + width: 22px; + height: 22px; +} /* bio + connections — wider, padded to match */ .presence-stage .pc-bio { @@ -3639,46 +4099,98 @@ body:has(.presence-stage) { padding: 0.7rem 0.9rem; font-size: 0.88rem; } + .presence-stage .pc-connections { margin: 0 1.4rem 0.9rem; gap: 0.5rem; } -.presence-stage .pc-conn { font-size: 0.74rem; padding: 0.3rem 0.65rem; } + +.presence-stage .pc-conn { + font-size: 0.74rem; + padding: 0.3rem 0.65rem; +} /* activity rows — larger artwork + text */ .presence-stage .pc-sections { gap: 0.6rem; padding: 0 1.4rem 1.1rem; } -.presence-stage .pc-row { padding: 0.7rem 0.8rem; border-radius: 14px; } + +.presence-stage .pc-row { + padding: 0.7rem 0.8rem; + border-radius: 14px; +} + .presence-stage .pc-art, .presence-stage .pc-row-ic-img, .presence-stage .pc-ic-wrap, -.presence-stage .pc-ic-wrap .pc-row-ic-img { width: 56px; height: 56px; } -.presence-stage .pc-row-title { font-size: 0.95rem; max-width: none; } -.presence-stage .pc-row-sub { font-size: 0.82rem; max-width: none; } -.presence-stage .pc-progress { width: 100%; } -.presence-stage .pc-custom-text { font-size: 0.86rem; max-width: none; } +.presence-stage .pc-ic-wrap .pc-row-ic-img { + width: 56px; + height: 56px; +} + +.presence-stage .pc-row-title { + font-size: 0.95rem; + max-width: none; +} + +.presence-stage .pc-row-sub { + font-size: 0.82rem; + max-width: none; +} + +.presence-stage .pc-progress { + width: 100%; +} + +.presence-stage .pc-custom-text { + font-size: 0.86rem; + max-width: none; +} /* wishlist + star scale up a touch */ -.presence-stage .pc-star { font-size: 1.2rem; } +.presence-stage .pc-star { + font-size: 1.2rem; +} @media (max-width: 720px) { - .presence-stage .presence-card { max-width: 100%; } + .presence-stage .presence-card { + max-width: 100%; + } } + @media (max-width: 480px) { - .presence-stage .pc-banner { height: 150px; } + .presence-stage .pc-banner { + height: 150px; + } + .presence-stage .pc-avatar, .presence-stage .presence-card.has-banner .pc-avatar, - .presence-stage .presence-card.has-banner-color .pc-avatar { width: 92px; height: 92px; } + .presence-stage .presence-card.has-banner-color .pc-avatar { + width: 92px; + height: 92px; + } + .presence-stage .pc-av-img, .presence-stage .presence-card.has-banner .pc-av-img, - .presence-stage .presence-card.has-banner-color .pc-av-img { width: 92px; height: 92px; } + .presence-stage .presence-card.has-banner-color .pc-av-img { + width: 92px; + height: 92px; + } + .presence-stage .presence-card.has-banner .pc-head, - .presence-stage .presence-card.has-banner-color .pc-head { margin-top: 0; } + .presence-stage .presence-card.has-banner-color .pc-head { + margin-top: 0; + } + .presence-stage .presence-card.has-banner .pc-avatar, - .presence-stage .presence-card.has-banner-color .pc-avatar { margin-top: -46px; } - .presence-stage .pc-name { font-size: 1.25rem; } + .presence-stage .presence-card.has-banner-color .pc-avatar { + margin-top: -46px; + } + + .presence-stage .pc-name { + font-size: 1.25rem; + } } /* ===================================================================== @@ -3693,10 +4205,13 @@ body:has(.presence-stage) { bottom: 1rem !important; z-index: 5 !important; } -#lc-embed > section { margin-bottom: 0 !important; } + +#lc-embed>section { + margin-bottom: 0 !important; +} /* the card */ -#lc-embed > section > div { +#lc-embed>section>div { background: var(--surface-0) !important; border: 1px solid rgba(var(--accent-rgb), 0.45) !important; border-radius: 14px !important; @@ -3710,17 +4225,29 @@ body:has(.presence-stage) { font-weight: 600 !important; transition: transform 0.12s ease, background 0.12s ease, color 0.12s ease !important; } + #lc-embed a:nth-child(1), -#lc-embed a:nth-child(3) { color: rgb(var(--accent-rgb)) !important; } -#lc-embed a:nth-child(2) { color: var(--lavender) !important; } +#lc-embed a:nth-child(3) { + color: rgb(var(--accent-rgb)) !important; +} + +#lc-embed a:nth-child(2) { + color: var(--lavender) !important; +} + #lc-embed a:hover { background: var(--surface-2) !important; transform: translateY(-2px) !important; } /* the "you are at " line */ -#lc-embed p { color: var(--subtext-0) !important; } -#lc-embed p span { color: rgb(var(--accent-rgb)) !important; } +#lc-embed p { + color: var(--subtext-0) !important; +} + +#lc-embed p span { + color: rgb(var(--accent-rgb)) !important; +} /* On mobile the body is a single flex column and every other floating widget (nav, badges, presence card) is reflowed into it. The keyring, @@ -3737,7 +4264,10 @@ body:has(.presence-stage) { display: flex !important; justify-content: center !important; } - #lc-embed > section > div { min-width: 0 !important; } + + #lc-embed>section>div { + min-width: 0 !important; + } } /* ===================================================================== @@ -3755,7 +4285,9 @@ body:has(.presence-stage) { box-shadow: 0 18px 50px -22px rgba(0, 0, 0, 0.6); } -.fronting-card[hidden] { display: none; } +.fronting-card[hidden] { + display: none; +} .fr-head { display: flex; @@ -3779,13 +4311,23 @@ body:has(.presence-stage) { } @keyframes fr-pulse { - 0% { box-shadow: 0 0 0 0 rgba(166, 227, 161, 0.5); } - 70% { box-shadow: 0 0 0 7px rgba(166, 227, 161, 0); } - 100% { box-shadow: 0 0 0 0 rgba(166, 227, 161, 0); } + 0% { + box-shadow: 0 0 0 0 rgba(166, 227, 161, 0.5); + } + + 70% { + box-shadow: 0 0 0 7px rgba(166, 227, 161, 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(166, 227, 161, 0); + } } @media (prefers-reduced-motion: reduce) { - .fr-dot { animation: none; } + .fr-dot { + animation: none; + } } .fr-members { @@ -3819,7 +4361,9 @@ body:has(.presence-stage) { background: var(--crust); } -.fr-av--empty { display: inline-block; } +.fr-av--empty { + display: inline-block; +} .fr-meta { display: flex; @@ -3861,12 +4405,15 @@ body:has(.selfies-wrap) { min-height: 100dvh; overflow-y: auto; } + body:has(.selfies-wrap) { align-items: flex-start; } + body:has(.selfies-wrap) .hub { max-width: 960px; } + body:has(.selfies-wrap) .hub-header { position: relative; z-index: 1; @@ -3944,6 +4491,7 @@ body:has(.selfies-wrap) .hub-header { from { opacity: 0; } + to { opacity: 1; } @@ -4020,14 +4568,17 @@ body.lightbox-open { } @media (prefers-reduced-motion: reduce) { + .selfie-thumb, .lightbox-close, .lightbox-nav { transition: none; } + .lightbox.is-open { animation: none; } + .lightbox-nav:hover { transform: translateY(-50%); } @@ -4038,6 +4589,7 @@ body.lightbox-open { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 0.6rem; } + .lightbox-nav { width: 2.6rem; height: 2.6rem; @@ -4106,28 +4658,55 @@ body.lightbox-open { bottom: auto; z-index: auto; margin: 0; - width: 300px; /* clearly smaller than the 680px /discord card */ + width: 300px; + /* clearly smaller than the 680px /discord card */ max-width: 100%; } /* keep things tidy at the small size */ -.presence-card.is-mini .pc-banner { height: 84px; } +.presence-card.is-mini .pc-banner { + height: 84px; +} + .presence-card.is-mini .pc-bio { max-height: 6.5em; overflow-y: auto; } /* the friend name can open a personal site */ -.presence-card.is-mini .pc-name--link { text-decoration: none; } -.presence-card.is-mini .pc-name--link:hover { text-decoration: underline; } +.presence-card.is-mini .pc-name--link { + text-decoration: none; +} + +.presence-card.is-mini .pc-name--link:hover { + text-decoration: underline; +} /* ---- tier hearts (ported from the old .fc-name prefixes) ---- */ -.presence-card.is-mini .pc-name::before { content: "🩵 "; } -.presence-card.is-mini.tier-known .pc-name::before { content: "💛 "; } -.presence-card.is-mini.tier-wife .pc-name::before { content: "🖤 "; } -.presence-card.is-mini.tier-close .pc-name::before { content: "🤍 "; } -.presence-card.is-mini.tier-active-alt .pc-name::before { content: "🎭 "; } -.presence-card.is-mini.tier-dead-alt .pc-name::before { content: "💀 "; } +.presence-card.is-mini .pc-name::before { + content: "🩵 "; +} + +.presence-card.is-mini.tier-known .pc-name::before { + content: "💛 "; +} + +.presence-card.is-mini.tier-wife .pc-name::before { + content: "🖤 "; +} + +.presence-card.is-mini.tier-close .pc-name::before { + content: "🤍 "; +} + +.presence-card.is-mini.tier-active-alt .pc-name::before { + content: "🎭 "; +} + +.presence-card.is-mini.tier-dead-alt .pc-name::before { + content: "💀 "; +} + /* gradient names clip text to transparent — keep the heart visible */ .presence-card.is-mini .pc-name.is-gradient::before { -webkit-text-fill-color: initial; @@ -4135,18 +4714,26 @@ body.lightbox-open { } /* ---- dead alts: greyed, struck through, no live status ---- */ -.presence-card.is-mini.tier-dead-alt .pc-av-img { filter: grayscale(1) brightness(0.6); } +.presence-card.is-mini.tier-dead-alt .pc-av-img { + filter: grayscale(1) brightness(0.6); +} + .presence-card.is-mini.tier-dead-alt .pc-name { color: var(--overlay-1); text-decoration: line-through; } -.presence-card.is-mini.tier-dead-alt .pc-status { display: none; } + +.presence-card.is-mini.tier-dead-alt .pc-status { + display: none; +} /* ============================================================ 16. Top artists — artist avatar in each chip (Deezer images) ============================================================ */ -.top-chip a { align-items: center; } +.top-chip a { + align-items: center; +} .top-art { width: 34px; @@ -4178,4 +4765,22 @@ body.lightbox-open { display: block; flex: none; } -.presence-card.is-mini .pc-conn-ic { width: 13px; height: 13px; } + +.presence-card.is-mini .pc-conn-ic { + width: 13px; + height: 13px; +} + +.friends-disclaimer { + margin: 2.5rem auto 0; + max-width: 60ch; + text-align: center; + font-size: .8rem; + line-height: 1.5; + opacity: .6; +} + +.friends-disclaimer a { + color: inherit; + text-decoration: underline; +} \ No newline at end of file diff --git a/css/themes/frappe.css b/css/themes/frappe.css index 8b50d3e..1038287 100644 --- a/css/themes/frappe.css +++ b/css/themes/frappe.css @@ -7,7 +7,8 @@ html[data-flavor="frappe"] { --rosewater: #f2d5cf; --flamingo: #eebebe; --pink: #f4b8e4; - --accent-rgb: 244, 184, 228; /* live accent default = theme pink */ + --accent-rgb: 244, 184, 228; + /* live accent default = theme pink */ --mauve: #ca9ee6; --red: #e78284; --maroon: #ea999c; diff --git a/css/themes/latte.css b/css/themes/latte.css index 5935512..69197eb 100644 --- a/css/themes/latte.css +++ b/css/themes/latte.css @@ -7,7 +7,8 @@ html[data-flavor="latte"] { --rosewater: #dc8a78; --flamingo: #dd7878; --pink: #ea76cb; - --accent-rgb: 234, 118, 203; /* live accent default = theme pink */ + --accent-rgb: 234, 118, 203; + /* live accent default = theme pink */ --mauve: #8839ef; --red: #d20f39; --maroon: #e64553; diff --git a/css/themes/macchiato.css b/css/themes/macchiato.css index b1e3a92..07d8e3b 100644 --- a/css/themes/macchiato.css +++ b/css/themes/macchiato.css @@ -7,7 +7,8 @@ html[data-flavor="macchiato"] { --rosewater: #f4dbd6; --flamingo: #f0c6c6; --pink: #f5bde6; - --accent-rgb: 245, 189, 230; /* live accent default = theme pink */ + --accent-rgb: 245, 189, 230; + /* live accent default = theme pink */ --mauve: #c6a0f6; --red: #ed8796; --maroon: #ee99a0; diff --git a/css/themes/mocha.css b/css/themes/mocha.css index abe8b0a..b2a253a 100644 --- a/css/themes/mocha.css +++ b/css/themes/mocha.css @@ -7,7 +7,8 @@ html[data-flavor="mocha"] { --rosewater: #f5e0dc; --flamingo: #f2cdcd; --pink: #f5c2e7; - --accent-rgb: 245, 194, 231; /* live accent default = theme pink */ + --accent-rgb: 245, 194, 231; + /* live accent default = theme pink */ --mauve: #cba6f7; --red: #f38ba8; --maroon: #eba0ac; diff --git a/dev-info/index.html b/dev-info/index.html index afe6a82..0744992 100644 --- a/dev-info/index.html +++ b/dev-info/index.html @@ -17,8 +17,10 @@ - - + + @@ -35,7 +37,8 @@ - + @@ -44,7 +47,8 @@ - + @@ -84,72 +88,117 @@
Tech Stack
- - - - - - + + + + + + - - - - - + + + + + - - - - - - + + + + + + - - - + + + - - - - - - + + + + + + - - - - - + + + + + - - - - - - - + + + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - + + +
@@ -159,43 +208,64 @@
Microphone
@@ -203,7 +273,8 @@
@@ -236,7 +307,8 @@
Arch btw
-
Daily driver is Arch Linux: rolling release, built up from the ground exactly how I like it.
+
Daily driver is Arch Linux: rolling release, built up from the ground exactly how I like it. +
Debian Pro
diff --git a/discord/index.html b/discord/index.html index f744616..ccf9257 100644 --- a/discord/index.html +++ b/discord/index.html @@ -76,14 +76,14 @@

Discord

-

What fae is up to, live via Lanyard.

+

What fae is up to, live via Doughmination Restful.

-
+
- + diff --git a/guestbook/index.html b/guestbook/index.html index e0fb961..d516e39 100644 --- a/guestbook/index.html +++ b/guestbook/index.html @@ -118,9 +118,8 @@
- - + \ No newline at end of file diff --git a/js/discord.js b/js/discord.js index 32e1acf..339d9e2 100644 --- a/js/discord.js +++ b/js/discord.js @@ -1,3 +1,14 @@ +// main.js — merged Discord presence module (formerly discord.js + friends.js). +// +// Part 1: the PresenceCard factory (window.PresenceCard) + the owner's own +// card, auto-mounted on #my-discord with a hardcoded DUID. +// Part 2: the friends/alts grid, auto-mounted on #friends-discord, built +// from the FRIENDS list using the same factory. +// +// HTML usage: +//
-> single full presence card (owner) +//
-> friend/alt grid of mini cards + (function () { "use strict"; @@ -35,793 +46,799 @@ if (!mount) return null; if (!DISCORD_USER_ID && !opts.fallbackName) return null; - // ---- theme: only on standalone api pages (homepage uses data-flavor) ---- - if (!document.documentElement.getAttribute("data-flavor")) { - const t = new URLSearchParams(location.search).get("theme"); - const themes = ["mocha", "macchiato", "frappe", "latte"]; - if (!document.documentElement.getAttribute("data-theme")) { - document.documentElement.setAttribute("data-theme", themes.indexOf(t) >= 0 ? t : "mocha"); - } - } - - // ---- build the card ----------------------------------------------------- - const card = document.createElement("div"); - // Only the single owner card claims id="discord" (core.js + the gold-cat - // observer key off it). Mini friend cards must not duplicate the id. - if (!opts.mini) card.id = "discord"; - card.className = "presence-card" + (opts.mini ? " is-mini" : "") + (opts.tier ? " tier-" + opts.tier : ""); - // Discord cards default to gg sans (Discord's own font) instead of the page's - // Comic Code. Per-name display fonts (set on .pc-name below) still override this. - card.style.fontFamily = "'DDN gg sans', sans-serif"; - card.hidden = true; - card.innerHTML = - '' + - '
' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
' + - '' + - '' + - '
' + - '
'; - mount.replaceWith(card); - - const avImg = card.querySelector(".pc-av-img"); - const avDeco = card.querySelector(".pc-av-deco"); - const nameEl = card.querySelector(".pc-name"); - const tagEl = card.querySelector(".pc-tag"); - const userEl = card.querySelector(".pc-user"); - const platformsEl = card.querySelector(".pc-platforms"); - const statusTextEl = card.querySelector(".pc-status-text"); - const STATUS_TITLE = { online: "Online", idle: "Idle", dnd: "Do Not Disturb", offline: "Offline" }; - const metaEl = card.querySelector(".pc-meta"); - const badgesEl = card.querySelector(".pc-badges"); - const sections = card.querySelector(".pc-sections"); - const idEl = card.querySelector(".pc-id"); - const starBtn = card.querySelector(".pc-star"); - const wishlistEl = card.querySelector(".pc-wishlist"); - const bannerEl = card.querySelector(".pc-banner"); - const bioEl = card.querySelector(".pc-bio"); - const connectionsEl = card.querySelector(".pc-connections"); - const pronounsEl = card.querySelector(".pc-pronouns"); - - // ---- friend-card extras: name link + instant placeholder ---------------- - // Optional website link on the name (friends can have a personal site). - if (opts.link) { - nameEl.classList.add("pc-name--link"); - nameEl.setAttribute("role", "link"); - nameEl.setAttribute("tabindex", "0"); - const goLink = function () { window.open(opts.link, "_blank", "noopener"); }; - nameEl.addEventListener("click", goLink); - nameEl.addEventListener("keydown", function (e) { - if (e.key === "Enter" || e.key === " ") { e.preventDefault(); goLink(); } - }); - } - // Seed a placeholder so the card shows instantly (and remains for ID-less or - // offline dead alts the API can't fill). render() overwrites it on success. - if (opts.fallbackName) { - nameEl.textContent = opts.fallbackName; - avImg.src = opts.fallbackImg || avatarUrl({ id: DISCORD_USER_ID }); - card.dataset.status = "offline"; - card.hidden = false; - } - - // ---- wishlist (revealed by the star) ------------------------------------ - // Items come straight from the Doughmination Restful API (j.data.wishlist): - // each is a resolved Shop item { sku_id, type, name, static_image_url, - // animated_image_url, video_url, label, is_owned, price, visibility }. - let wishlistItems = null; - const WL_TYPE_LABEL = { - avatar_decoration: "Decoration", - profile_effect: "Effect", - nameplate: "Nameplate", - bundle: "Bundle", - variants_group: "Variants", - external_sku: "Item" - }; - const CURRENCY_SYMBOL = { gbp: "£", usd: "$", eur: "€", aud: "A$", cad: "C$" }; - function fmtPrice(p) { - if (!p || typeof p.amount !== "number") return null; - const exp = typeof p.exponent === "number" ? p.exponent : 2; - const v = (p.amount / Math.pow(10, exp)).toFixed(exp); - const sym = CURRENCY_SYMBOL[(p.currency || "").toLowerCase()]; - return sym ? sym + v : v + " " + String(p.currency || "").toUpperCase(); - } - // Pick a thumbnail and decide whether the wsrv webp proxy is safe: avatar - // decorations and profile effects are animated APNGs the proxy mangles (the - // same reason the avatar decoration loads raw), so those go straight to the - // CDN; nameplates and the rest are static and proxy fine. - function wlImg(w) { - const url = w.static_image_url || w.animated_image_url; - if (!url) return null; - if (w.type === "avatar_decoration" || w.type === "profile_effect" || /avatar-decoration-presets/.test(url)) { - return url; - } - return proxyImg(url, { w: 64 }) || url; - } - function renderWishlist() { - if (!wishlistEl) return; - const items = Array.isArray(wishlistItems) ? wishlistItems : []; - let body; - if (items.length) { - body = items.map(function (w) { - const ic = wlImg(w); - const typeLabel = WL_TYPE_LABEL[w.type] || ""; - const price = fmtPrice(w.price); - return '' + - (ic ? '' : "") + - '' + - '' + esc(w.name || "Collectible") + "" + - (typeLabel ? '' + esc(typeLabel) + "" : "") + - "" + - (price ? '' + esc(price) + "" : "") + - ""; - }).join(""); - } else { - body = '

nothing on the wishlist yet ✨

'; - } - wishlistEl.innerHTML = '
Wishlist
' + body; - } - if (starBtn) { - starBtn.addEventListener("click", function (e) { - e.stopPropagation(); - const open = card.classList.toggle("show-wishlist"); - starBtn.classList.toggle("on", open); - starBtn.setAttribute("aria-expanded", open ? "true" : "false"); - if (open) renderWishlist(); - }); - } - - let latest = null; - let customNode = null; - let ticker = null; - let ws = null; - let heartbeat = null; - let reconnectDelay = 1000; - - // ---- small helpers ------------------------------------------------------ - function fmt(ms) { - const total = Math.max(0, Math.floor(ms / 1000)); - const m = Math.floor(total / 60); - const s = total % 60; - return `${m}:${String(s).padStart(2, "0")}`; - } - function elapsedStr(start) { - const s = Math.max(0, Math.floor((Date.now() - start) / 1000)); - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - return h ? `${h}h ${m}m` : `${m}m`; - } - function clamp(n, lo, hi) { return Math.min(Math.max(n, lo), hi); } - - // pages.gay is a static host with no server-side compute, so we can't run - // our own proxy. wsrv.nl is a free, cookieless image CDN: it re-serves the - // image with no Set-Cookie (killing the third-party __cf_bm cookie) and can - // convert to WebP on the fly. `opts` lets callers request a resize. - function proxyImg(url, opts) { - if (!url) return url; - if (!/^https:\/\/(cdn|media)\.discordapp\.(com|net)\//.test(url)) return url; - const src = url.replace(/^https:\/\//, ""); - let q = "https://wsrv.nl/?url=" + encodeURIComponent(src) + "&output=webp"; - if (opts && opts.w) q += "&w=" + opts.w + "&dpr=2&fit=cover"; - return q; - } - - function avatarUrl(u) { - if (!u || !u.avatar) return proxyImg("https://cdn.discordapp.com/embed/avatars/0.png"); - const ext = String(u.avatar).startsWith("a_") ? "gif" : "png"; - return proxyImg(`https://cdn.discordapp.com/avatars/${u.id}/${u.avatar}.${ext}?size=128`, { w: 80 }); - } - function emojiUrl(e) { - if (!e || !e.id) return null; - return proxyImg(`https://cdn.discordapp.com/emojis/${e.id}.${e.animated ? "gif" : "png"}?size=32`); - } - function assetUrl(appId, asset) { - if (!asset) return null; - if (String(asset).startsWith("mp:")) return proxyImg("https://media.discordapp.net/" + asset.slice(3)); - return proxyImg(`https://cdn.discordapp.com/app-assets/${appId}/${asset}.png`); - } - function esc(str) { - return String(str == null ? "" : str) - .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - } - function intToHex(n) { - return "#" + (Number(n) >>> 0).toString(16).padStart(6, "0").slice(-6); - } - function guildBadgeUrl(pg) { - if (!pg || !pg.badge || !pg.identity_guild_id) return null; - return proxyImg(`https://cdn.discordapp.com/guild-tag-badges/${pg.identity_guild_id}/${pg.badge}.png?size=24`); - } - const PLATFORM_ICONS = { - desktop: '', - mobile: '', - web: '' - }; - function platformIcons(d) { - let html = ""; - if (d.active_on_discord_desktop) html += '' + PLATFORM_ICONS.desktop + ""; - if (d.active_on_discord_mobile) html += '' + PLATFORM_ICONS.mobile + ""; - if (d.active_on_discord_web || d.active_on_discord_embedded) html += '' + PLATFORM_ICONS.web + ""; - return html; - } - const BADGE_FLAGS = [ - [1 << 0, "Discord Staff", "5e74e9b61934fc1f67c65515d1f7e60d"], - [1 << 1, "Partnered Server Owner", "3f9748e53446a137a052f3454e2de41e"], - [1 << 2, "HypeSquad Events", "bf01d1073931f921909045f3a39fd264"], - [1 << 3, "Bug Hunter", "2717692c7dca7289b35297368a940dd0"], - [1 << 6, "HypeSquad Bravery", "8a88d63823d8a71cd5e390baa45efa02"], - [1 << 7, "HypeSquad Brilliance", "011940fd013da3f7fb926e4a1cd2e618"], - [1 << 8, "HypeSquad Balance", "3aa41de486fa12454c3761e8e223442e"], - [1 << 9, "Early Supporter", "7060786766c9c840eb3019e725d2b358"], - [1 << 14, "Bug Hunter Gold", "848f79194d4be5ff5f81505cbd0ce1e6"], - [1 << 17, "Early Verified Bot Developer", "6df5892e0f35b051f8b61eace34f4967"], - [1 << 18, "Moderator Programs Alumni", "fee1624003e2fee35cb398e125dc479b"], - [1 << 22, "Active Developer", "6bdc42827a38498929a4920da12695d9"] - ]; - function renderBadges(flags) { - flags = Number(flags) || 0; - let html = ""; - for (const [bit, name, hash] of BADGE_FLAGS) { - if (flags & bit) { - html += '' + esc(name) + ''; + // ---- theme: only on standalone api pages (homepage uses data-flavor) ---- + if (!document.documentElement.getAttribute("data-flavor")) { + const t = new URLSearchParams(location.search).get("theme"); + const themes = ["mocha", "macchiato", "frappe", "latte"]; + if (!document.documentElement.getAttribute("data-theme")) { + document.documentElement.setAttribute("data-theme", themes.indexOf(t) >= 0 ? t : "mocha"); } } - return html; - } - // Richer badges via dstn.to — Nitro, boosts, quests, orbs… everything - // Discord actually shows, which public_flags (0 for most) can't give. - let dstnBadges = null; - let lastFlags = 0; - function renderDstnBadges() { - return dstnBadges.map(function (b) { - const img = '' + esc(b.description || b.id) + ''; - return b.link - ? '' + img + "" - : img; - }).join(""); - } - function paintBadges() { - if (!badgesEl) return; - badgesEl.innerHTML = (dstnBadges && dstnBadges.length) ? renderDstnBadges() : renderBadges(lastFlags); - } - function rgbTriplet(n) { - n = Number(n) >>> 0; - return ((n >> 16) & 255) + ", " + ((n >> 8) & 255) + ", " + (n & 255); - } - function applyProfileGradient(colors) { - if (!colors || colors.length < 2) return; - card.style.setProperty("--pc-grad-1-rgb", rgbTriplet(colors[0])); - card.style.setProperty("--pc-grad-2-rgb", rgbTriplet(colors[1])); - card.classList.add("has-profile-grad"); - } - // ---- banner / bio / connected accounts (extras for the /discord page) --- - function bannerUrl(id, hash) { - if (!id || !hash) return null; - // Animated banners (a_) must be requested WITHOUT the .gif extension: - // Discord's CDN throws HTTP 415 for some a_*.gif banners, but the - // extension-less URL works (wsrv then re-serves it as cookieless webp). - const animated = String(hash).startsWith("a_"); - const url = "https://cdn.discordapp.com/banners/" + id + "/" + hash + (animated ? "" : ".png") + "?size=600"; - return proxyImg(url, { w: 600 }); - } - function applyBanner(url, fallbackColor) { - if (!bannerEl) return; - if (url) { - bannerEl.src = url; - bannerEl.hidden = false; - bannerEl.onerror = function () { bannerEl.hidden = true; card.classList.remove("has-banner"); }; - card.classList.add("has-banner"); - } else if (fallbackColor) { - bannerEl.hidden = true; - card.style.setProperty("--pc-banner-color", fallbackColor); - card.classList.add("has-banner-color"); - } - } - function renderBio(text) { - if (!bioEl) return; - if (text && String(text).trim()) { - bioEl.textContent = String(text).trim(); - bioEl.hidden = false; - } else { - bioEl.hidden = true; - } - } - // Best-effort profile links for the common connection types. - const CONNECTION_URLS = { - tiktok: function (n) { return "https://tiktok.com/@" + n; }, - ebay: function (n) { return "https://www.ebay.com/usr/" + n; }, - instagram: function (n) {return "https://www.instagram.com/" + n; }, - xbox: function (n) {return "https://www.xbox.com/en-GB/play/user/" + n; }, - github: function (n) { return "https://github.com/" + n; }, - roblox: function (n, id) { return "https://www.roblox.com/users/" + id + "/profile";}, - epicgames: function (n, id) {return "https://store.epicgames.com/u/" + id; }, - twitter: function (n) { return "https://twitter.com/" + n; }, - twitch: function (n) { return "https://twitch.tv/" + n; }, - youtube: function (n, id) { return "https://youtube.com/channel/" + id; }, - spotify: function (n, id) { return "https://open.spotify.com/user/" + id; }, - steam: function (n, id) { return "https://steamcommunity.com/profiles/" + id; }, - reddit: function (n) { return "https://reddit.com/user/" + n; }, - instagram: function (n) { return "https://instagram.com/" + n; }, - domain: function (n) { return "https://" + n; }, - bluesky: function (n) { return "https://bsky.app/profile/" + n; } - }; - // connection type -> brand SVG in /assets/socials (anything unmapped uses - // the generic globe "site.svg") - const CONNECTION_ICON = { - "amazon-music": "amazon", - facebook: "facebook", - ebay: "ebay", - tiktok: "tiktok", - bungie: "bungie", // - playstation: "playstation", - paypal: "paypal", - instagram: "instagram", - xbox: "xbox", - crunchyroll: "crunchyroll", - battlenet: "battlenet", - github: "github", - epicgames: "epic", - riotgames: "riot", - leagueoflegends: "league", - steam: "steam", - roblox: "roblox", - twitter: "twitter", - bluesky: "bluesky", - mastodon: "mastodon", - twitch: "twitch", - youtube: "youtube", - reddit: "reddit", - spotify: "spotify", - discord: "discord", - linkedin: "linkedin", - domain: "site" - }; - function connIcon(type) { - const file = CONNECTION_ICON[String(type || "").toLowerCase()] || "site"; - return '' +
-      esc(type) + ''; - } - function renderConnections(accounts) { - if (!connectionsEl) return; - const list = (accounts || []).filter(function (a) { return a && a.name; }); - if (!list.length) { connectionsEl.hidden = true; return; } - connectionsEl.innerHTML = list.map(function (a) { - const maker = CONNECTION_URLS[a.type]; - const url = maker ? maker(a.name, a.id) : null; - const inner = connIcon(a.type) + - '' + esc(a.name) + "" + - (a.verified ? '' : ""); - return url - ? '' + inner + "" - : '' + inner + ""; - }).join(""); - connectionsEl.hidden = false; - } + // ---- build the card ----------------------------------------------------- + const card = document.createElement("div"); + // Only the single owner card claims id="discord" (core.js + the gold-cat + // observer key off it). Mini friend cards must not duplicate the id. + if (!opts.mini) card.id = "discord"; + card.className = "presence-card" + (opts.mini ? " is-mini" : "") + (opts.tier ? " tier-" + opts.tier : ""); + // Discord cards default to gg sans (Discord's own font) instead of the page's + // Comic Code. Per-name display fonts (set on .pc-name below) still override this. + card.style.fontFamily = "'DDN gg sans', sans-serif"; + card.hidden = true; + card.innerHTML = + '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
'; + mount.replaceWith(card); - // ---- album-art → Catppuccin accent -------------------------------------- - const ACCENT_VARS = [ - "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", - "yellow", "green", "teal", "sky", "saphire", "blue", "lavender", - ]; - function hexToRgb(hex) { - hex = hex.trim().replace("#", ""); - if (hex.length === 3) hex = hex.split("").map((c) => c + c).join(""); - const n = parseInt(hex, 16); - return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; - } - function getThemePalette() { - const cs = getComputedStyle(document.documentElement); - const pal = []; - for (const name of ACCENT_VARS) { - const v = cs.getPropertyValue("--" + name).trim(); - if (v.startsWith("#")) { const [r, g, b] = hexToRgb(v); pal.push({ r, g, b }); } + const avImg = card.querySelector(".pc-av-img"); + const avDeco = card.querySelector(".pc-av-deco"); + const nameEl = card.querySelector(".pc-name"); + const tagEl = card.querySelector(".pc-tag"); + const userEl = card.querySelector(".pc-user"); + const platformsEl = card.querySelector(".pc-platforms"); + const statusTextEl = card.querySelector(".pc-status-text"); + const STATUS_TITLE = { online: "Online", idle: "Idle", dnd: "Do Not Disturb", offline: "Offline" }; + const metaEl = card.querySelector(".pc-meta"); + const badgesEl = card.querySelector(".pc-badges"); + const sections = card.querySelector(".pc-sections"); + const idEl = card.querySelector(".pc-id"); + const starBtn = card.querySelector(".pc-star"); + const wishlistEl = card.querySelector(".pc-wishlist"); + const bannerEl = card.querySelector(".pc-banner"); + const bioEl = card.querySelector(".pc-bio"); + const connectionsEl = card.querySelector(".pc-connections"); + const pronounsEl = card.querySelector(".pc-pronouns"); + + // ---- friend-card extras: name link + instant placeholder ---------------- + // Optional website link on the name (friends can have a personal site). + if (opts.link) { + nameEl.classList.add("pc-name--link"); + nameEl.setAttribute("role", "link"); + nameEl.setAttribute("tabindex", "0"); + const goLink = function () { window.open(opts.link, "_blank", "noopener"); }; + nameEl.addEventListener("click", goLink); + nameEl.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); goLink(); } + }); } - return pal; - } - function nearestAccent(r, g, b) { - const pal = getThemePalette(); - let best = null, bestD = Infinity; - for (const c of pal) { - const rm = (r + c.r) / 2, dr = r - c.r, dg = g - c.g, db = b - c.b; - const d = (2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db; - if (d < bestD) { bestD = d; best = c; } + // Seed a placeholder so the card shows instantly (and remains for ID-less or + // offline dead alts the API can't fill). render() overwrites it on success. + if (opts.fallbackName) { + nameEl.textContent = opts.fallbackName; + avImg.src = opts.fallbackImg || avatarUrl({ id: DISCORD_USER_ID }); + card.dataset.status = "offline"; + card.hidden = false; } - return best; - } - let lastArtUrl = null; - function applyAccent(url) { - if (!url || url === lastArtUrl) return; - lastArtUrl = url; - const img = new Image(); - img.crossOrigin = "anonymous"; - img.referrerPolicy = "no-referrer"; - img.onload = () => { - try { - const c = document.createElement("canvas"); - c.width = c.height = 16; - const ctx = c.getContext("2d", { willReadFrequently: true }); - ctx.drawImage(img, 0, 0, 16, 16); - const { data } = ctx.getImageData(0, 0, 16, 16); - let r = 0, g = 0, b = 0, count = 0; - for (let i = 0; i < data.length; i += 4) { - if (data[i + 3] < 125) continue; - const lum = 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]; - if (lum < 24 || lum > 235) continue; - r += data[i]; g += data[i + 1]; b += data[i + 2]; count++; - } - if (!count) { resetAccent(); return; } - r = Math.round(r / count); g = Math.round(g / count); b = Math.round(b / count); - const near = nearestAccent(r, g, b); - const rgb = near ? `${near.r}, ${near.g}, ${near.b}` : `${r}, ${g}, ${b}`; - card.style.setProperty("--dc-accent", rgb); - card.classList.add("has-accent"); - // Only the full-size card recolours the whole page; mini friend cards - // keep their accent local so they don't fight over --accent-rgb. - if (!opts.mini) document.documentElement.style.setProperty("--accent-rgb", rgb); - } catch (e) { resetAccent(); } + // Optional @username sub-row for ID-less placeholder cards (e.g. dead alts + // whose display name differs from their actual username). render() will + // overwrite this with the live username if/when real API data comes in. + if (opts.fallbackUser) { + userEl.textContent = "@" + opts.fallbackUser; + } + + // ---- wishlist (revealed by the star) ------------------------------------ + // Items come straight from the Doughmination Restful API (j.data.wishlist): + // each is a resolved Shop item { sku_id, type, name, static_image_url, + // animated_image_url, video_url, label, is_owned, price, visibility }. + let wishlistItems = null; + const WL_TYPE_LABEL = { + avatar_decoration: "Decoration", + profile_effect: "Effect", + nameplate: "Nameplate", + bundle: "Bundle", + variants_group: "Variants", + external_sku: "Item" }; - img.onerror = resetAccent; - img.src = url; - } - function resetAccent() { - lastArtUrl = null; - card.classList.remove("has-accent"); - card.style.removeProperty("--dc-accent"); - if (!opts.mini) document.documentElement.style.removeProperty("--accent-rgb"); - } + const CURRENCY_SYMBOL = { gbp: "£", usd: "$", eur: "€", aud: "A$", cad: "C$" }; + function fmtPrice(p) { + if (!p || typeof p.amount !== "number") return null; + const exp = typeof p.exponent === "number" ? p.exponent : 2; + const v = (p.amount / Math.pow(10, exp)).toFixed(exp); + const sym = CURRENCY_SYMBOL[(p.currency || "").toLowerCase()]; + return sym ? sym + v : v + " " + String(p.currency || "").toUpperCase(); + } + // Pick a thumbnail and decide whether the wsrv webp proxy is safe: avatar + // decorations and profile effects are animated APNGs the proxy mangles (the + // same reason the avatar decoration loads raw), so those go straight to the + // CDN; nameplates and the rest are static and proxy fine. + function wlImg(w) { + const url = w.static_image_url || w.animated_image_url; + if (!url) return null; + if (w.type === "avatar_decoration" || w.type === "profile_effect" || /avatar-decoration-presets/.test(url)) { + return url; + } + return proxyImg(url, { w: 64 }) || url; + } + function renderWishlist() { + if (!wishlistEl) return; + const items = Array.isArray(wishlistItems) ? wishlistItems : []; + let body; + if (items.length) { + body = items.map(function (w) { + const ic = wlImg(w); + const typeLabel = WL_TYPE_LABEL[w.type] || ""; + const price = fmtPrice(w.price); + return '' + + (ic ? '' : "") + + '' + + '' + esc(w.name || "Collectible") + "" + + (typeLabel ? '' + esc(typeLabel) + "" : "") + + "" + + (price ? '' + esc(price) + "" : "") + + ""; + }).join(""); + } else { + body = '

nothing on the wishlist yet ✨

'; + } + wishlistEl.innerHTML = '
Wishlist
' + body; + } + if (starBtn) { + starBtn.addEventListener("click", function (e) { + e.stopPropagation(); + const open = card.classList.toggle("show-wishlist"); + starBtn.classList.toggle("on", open); + starBtn.setAttribute("aria-expanded", open ? "true" : "false"); + if (open) renderWishlist(); + }); + } - // ---- section (row) builders -------------------------------------------- - function rowText(kind, title, sub, extra) { - return ( - '' + + let latest = null; + let customNode = null; + let ticker = null; + let ws = null; + let heartbeat = null; + let reconnectDelay = 1000; + + // ---- small helpers ------------------------------------------------------ + function fmt(ms) { + const total = Math.max(0, Math.floor(ms / 1000)); + const m = Math.floor(total / 60); + const s = total % 60; + return `${m}:${String(s).padStart(2, "0")}`; + } + function elapsedStr(start) { + const s = Math.max(0, Math.floor((Date.now() - start) / 1000)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + return h ? `${h}h ${m}m` : `${m}m`; + } + function clamp(n, lo, hi) { return Math.min(Math.max(n, lo), hi); } + + // pages.gay is a static host with no server-side compute, so we can't run + // our own proxy. wsrv.nl is a free, cookieless image CDN: it re-serves the + // image with no Set-Cookie (killing the third-party __cf_bm cookie) and can + // convert to WebP on the fly. `opts` lets callers request a resize. + function proxyImg(url, opts) { + if (!url) return url; + if (!/^https:\/\/(cdn|media)\.discordapp\.(com|net)\//.test(url)) return url; + const src = url.replace(/^https:\/\//, ""); + let q = "https://wsrv.nl/?url=" + encodeURIComponent(src) + "&output=webp"; + if (opts && opts.w) q += "&w=" + opts.w + "&dpr=2&fit=cover"; + return q; + } + + function avatarUrl(u) { + if (!u || !u.avatar) return proxyImg("https://cdn.discordapp.com/embed/avatars/0.png"); + const ext = String(u.avatar).startsWith("a_") ? "gif" : "png"; + return proxyImg(`https://cdn.discordapp.com/avatars/${u.id}/${u.avatar}.${ext}?size=128`, { w: 80 }); + } + function emojiUrl(e) { + if (!e || !e.id) return null; + return proxyImg(`https://cdn.discordapp.com/emojis/${e.id}.${e.animated ? "gif" : "png"}?size=32`); + } + function assetUrl(appId, asset) { + if (!asset) return null; + if (String(asset).startsWith("mp:")) return proxyImg("https://media.discordapp.net/" + asset.slice(3)); + return proxyImg(`https://cdn.discordapp.com/app-assets/${appId}/${asset}.png`); + } + function esc(str) { + return String(str == null ? "" : str) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function intToHex(n) { + return "#" + (Number(n) >>> 0).toString(16).padStart(6, "0").slice(-6); + } + function guildBadgeUrl(pg) { + if (!pg || !pg.badge || !pg.identity_guild_id) return null; + return proxyImg(`https://cdn.discordapp.com/guild-tag-badges/${pg.identity_guild_id}/${pg.badge}.png?size=24`); + } + const PLATFORM_ICONS = { + desktop: '', + mobile: '', + web: '' + }; + function platformIcons(d) { + let html = ""; + if (d.active_on_discord_desktop) html += '' + PLATFORM_ICONS.desktop + ""; + if (d.active_on_discord_mobile) html += '' + PLATFORM_ICONS.mobile + ""; + if (d.active_on_discord_web || d.active_on_discord_embedded) html += '' + PLATFORM_ICONS.web + ""; + return html; + } + const BADGE_FLAGS = [ + [1 << 0, "Discord Staff", "5e74e9b61934fc1f67c65515d1f7e60d"], + [1 << 1, "Partnered Server Owner", "3f9748e53446a137a052f3454e2de41e"], + [1 << 2, "HypeSquad Events", "bf01d1073931f921909045f3a39fd264"], + [1 << 3, "Bug Hunter", "2717692c7dca7289b35297368a940dd0"], + [1 << 6, "HypeSquad Bravery", "8a88d63823d8a71cd5e390baa45efa02"], + [1 << 7, "HypeSquad Brilliance", "011940fd013da3f7fb926e4a1cd2e618"], + [1 << 8, "HypeSquad Balance", "3aa41de486fa12454c3761e8e223442e"], + [1 << 9, "Early Supporter", "7060786766c9c840eb3019e725d2b358"], + [1 << 14, "Bug Hunter Gold", "848f79194d4be5ff5f81505cbd0ce1e6"], + [1 << 17, "Early Verified Bot Developer", "6df5892e0f35b051f8b61eace34f4967"], + [1 << 18, "Moderator Programs Alumni", "fee1624003e2fee35cb398e125dc479b"], + [1 << 22, "Active Developer", "6bdc42827a38498929a4920da12695d9"] + ]; + function renderBadges(flags) { + flags = Number(flags) || 0; + let html = ""; + for (const [bit, name, hash] of BADGE_FLAGS) { + if (flags & bit) { + html += '' + esc(name) + ''; + } + } + return html; + } + + // Richer badges via dstn.to — Nitro, boosts, quests, orbs… everything + // Discord actually shows, which public_flags (0 for most) can't give. + let dstnBadges = null; + let lastFlags = 0; + function renderDstnBadges() { + return dstnBadges.map(function (b) { + const img = '' + esc(b.description || b.id) + ''; + return b.link + ? '' + img + "" + : img; + }).join(""); + } + function paintBadges() { + if (!badgesEl) return; + badgesEl.innerHTML = (dstnBadges && dstnBadges.length) ? renderDstnBadges() : renderBadges(lastFlags); + } + function rgbTriplet(n) { + n = Number(n) >>> 0; + return ((n >> 16) & 255) + ", " + ((n >> 8) & 255) + ", " + (n & 255); + } + function applyProfileGradient(colors) { + if (!colors || colors.length < 2) return; + card.style.setProperty("--pc-grad-1-rgb", rgbTriplet(colors[0])); + card.style.setProperty("--pc-grad-2-rgb", rgbTriplet(colors[1])); + card.classList.add("has-profile-grad"); + } + // ---- banner / bio / connected accounts (extras for the /discord page) --- + function bannerUrl(id, hash) { + if (!id || !hash) return null; + // Animated banners (a_) must be requested WITHOUT the .gif extension: + // Discord's CDN throws HTTP 415 for some a_*.gif banners, but the + // extension-less URL works (wsrv then re-serves it as cookieless webp). + const animated = String(hash).startsWith("a_"); + const url = "https://cdn.discordapp.com/banners/" + id + "/" + hash + (animated ? "" : ".png") + "?size=600"; + return proxyImg(url, { w: 600 }); + } + function applyBanner(url, fallbackColor) { + if (!bannerEl) return; + if (url) { + bannerEl.src = url; + bannerEl.hidden = false; + bannerEl.onerror = function () { bannerEl.hidden = true; card.classList.remove("has-banner"); }; + card.classList.add("has-banner"); + } else if (fallbackColor) { + bannerEl.hidden = true; + card.style.setProperty("--pc-banner-color", fallbackColor); + card.classList.add("has-banner-color"); + } + } + function renderBio(text) { + if (!bioEl) return; + if (text && String(text).trim()) { + bioEl.textContent = String(text).trim(); + bioEl.hidden = false; + } else { + bioEl.hidden = true; + } + } + // Best-effort profile links for the common connection types. + const CONNECTION_URLS = { + tiktok: function (n) { return "https://tiktok.com/@" + n; }, + ebay: function (n) { return "https://www.ebay.com/usr/" + n; }, + instagram: function (n) { return "https://www.instagram.com/" + n; }, + xbox: function (n) { return "https://www.xbox.com/en-GB/play/user/" + n; }, + github: function (n) { return "https://github.com/" + n; }, + roblox: function (n, id) { return "https://www.roblox.com/users/" + id + "/profile"; }, + epicgames: function (n, id) { return "https://store.epicgames.com/u/" + id; }, + twitter: function (n) { return "https://twitter.com/" + n; }, + twitch: function (n) { return "https://twitch.tv/" + n; }, + youtube: function (n, id) { return "https://youtube.com/channel/" + id; }, + spotify: function (n, id) { return "https://open.spotify.com/user/" + id; }, + steam: function (n, id) { return "https://steamcommunity.com/profiles/" + id; }, + reddit: function (n) { return "https://reddit.com/user/" + n; }, + instagram: function (n) { return "https://instagram.com/" + n; }, + domain: function (n) { return "https://" + n; }, + bluesky: function (n) { return "https://bsky.app/profile/" + n; } + }; + // connection type -> brand SVG in /assets/socials (anything unmapped uses + // the generic globe "site.svg") + const CONNECTION_ICON = { + "amazon-music": "amazon", + facebook: "facebook", + ebay: "ebay", + tiktok: "tiktok", + bungie: "bungie", // + playstation: "playstation", + paypal: "paypal", + instagram: "instagram", + xbox: "xbox", + crunchyroll: "crunchyroll", + battlenet: "battlenet", + github: "github", + epicgames: "epic", + riotgames: "riot", + leagueoflegends: "league", + steam: "steam", + roblox: "roblox", + twitter: "twitter", + bluesky: "bluesky", + mastodon: "mastodon", + twitch: "twitch", + youtube: "youtube", + reddit: "reddit", + spotify: "spotify", + discord: "discord", + linkedin: "linkedin", + domain: "site" + }; + function connIcon(type) { + const file = CONNECTION_ICON[String(type || "").toLowerCase()] || "site"; + return '' +
+        esc(type) + ''; + } + function renderConnections(accounts) { + if (!connectionsEl) return; + const list = (accounts || []).filter(function (a) { return a && a.name; }); + if (!list.length) { connectionsEl.hidden = true; return; } + connectionsEl.innerHTML = list.map(function (a) { + const maker = CONNECTION_URLS[a.type]; + const url = maker ? maker(a.name, a.id) : null; + const inner = connIcon(a.type) + + '' + esc(a.name) + "" + + (a.verified ? '' : ""); + return url + ? '' + inner + "" + : '' + inner + ""; + }).join(""); + connectionsEl.hidden = false; + } + + // ---- album-art → Catppuccin accent -------------------------------------- + const ACCENT_VARS = [ + "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", + "yellow", "green", "teal", "sky", "saphire", "blue", "lavender", + ]; + function hexToRgb(hex) { + hex = hex.trim().replace("#", ""); + if (hex.length === 3) hex = hex.split("").map((c) => c + c).join(""); + const n = parseInt(hex, 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + } + function getThemePalette() { + const cs = getComputedStyle(document.documentElement); + const pal = []; + for (const name of ACCENT_VARS) { + const v = cs.getPropertyValue("--" + name).trim(); + if (v.startsWith("#")) { const [r, g, b] = hexToRgb(v); pal.push({ r, g, b }); } + } + return pal; + } + function nearestAccent(r, g, b) { + const pal = getThemePalette(); + let best = null, bestD = Infinity; + for (const c of pal) { + const rm = (r + c.r) / 2, dr = r - c.r, dg = g - c.g, db = b - c.b; + const d = (2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db; + if (d < bestD) { bestD = d; best = c; } + } + return best; + } + let lastArtUrl = null; + function applyAccent(url) { + if (!url || url === lastArtUrl) return; + lastArtUrl = url; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.referrerPolicy = "no-referrer"; + img.onload = () => { + try { + const c = document.createElement("canvas"); + c.width = c.height = 16; + const ctx = c.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(img, 0, 0, 16, 16); + const { data } = ctx.getImageData(0, 0, 16, 16); + let r = 0, g = 0, b = 0, count = 0; + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] < 125) continue; + const lum = 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]; + if (lum < 24 || lum > 235) continue; + r += data[i]; g += data[i + 1]; b += data[i + 2]; count++; + } + if (!count) { resetAccent(); return; } + r = Math.round(r / count); g = Math.round(g / count); b = Math.round(b / count); + const near = nearestAccent(r, g, b); + const rgb = near ? `${near.r}, ${near.g}, ${near.b}` : `${r}, ${g}, ${b}`; + card.style.setProperty("--dc-accent", rgb); + card.classList.add("has-accent"); + // Only the full-size card recolours the whole page; mini friend cards + // keep their accent local so they don't fight over --accent-rgb. + if (!opts.mini) document.documentElement.style.setProperty("--accent-rgb", rgb); + } catch (e) { resetAccent(); } + }; + img.onerror = resetAccent; + img.src = url; + } + function resetAccent() { + lastArtUrl = null; + card.classList.remove("has-accent"); + card.style.removeProperty("--dc-accent"); + if (!opts.mini) document.documentElement.style.removeProperty("--accent-rgb"); + } + + // ---- section (row) builders -------------------------------------------- + function rowText(kind, title, sub, extra) { + return ( + '' + '' + esc(kind) + "" + '' + esc(title) + "" + '' + esc(sub) + "" + (extra || "") + - "" - ); - } - - function customRow(a) { - const row = document.createElement("div"); - row.className = "pc-row pc-custom"; - const eu = emojiUrl(a.emoji); - row.innerHTML = - (eu ? '' - : '') + - '' + esc(a.state || "") + ""; - return row; - } - - function spotifyRow(s) { - const row = document.createElement("a"); - row.className = "pc-row pc-spotify"; - row.target = "_blank"; - row.rel = "noopener"; - row.href = s.track_id ? "https://open.spotify.com/track/" + s.track_id : "https://open.spotify.com/"; - if (s.album) row.title = (s.song || "") + " — " + s.album; - if (s.timestamps && s.timestamps.start) row.dataset.start = s.timestamps.start; - if (s.timestamps && s.timestamps.end) row.dataset.end = s.timestamps.end; - row.innerHTML = - (s.album_art_url ? '' : "") + - rowText("Listening to Spotify", s.song || "", s.artist || "", - '"); - return row; - } - - // Generic activity row (type 0). Discord presence exposes no link for - // games or apps, so this renders as a non-clickable card. - function activityRow(a) { - const isCode = /visual studio code|vscode/i.test(a.name || ""); - const row = document.createElement("div"); - row.className = "pc-row pc-row--stack " + (isCode ? "pc-dev" : "pc-game"); - if (a.timestamps && a.timestamps.start) row.dataset.elapsedStart = a.timestamps.start; - - const large = a.assets && a.assets.large_image && assetUrl(a.application_id, a.assets.large_image); - const small = a.assets && a.assets.small_image && assetUrl(a.application_id, a.assets.small_image); - const iconHtml = large - ? '' + - '' + - (small ? '' : "") + "" - : ''; - - let kind = isCode ? "Coding" : "Playing " + (a.name || ""); - if (a.party && a.party.size && a.party.size.length === 2 && a.party.size[1]) { - kind += " · " + a.party.size[0] + " of " + a.party.size[1]; + ); } - const main = document.createElement("div"); - main.className = "pc-row-link"; - main.innerHTML = iconHtml + - rowText(kind, a.details || (isCode ? "" : a.name) || "", - a.state || (a.assets && a.assets.large_text) || "", - ''); - row.appendChild(main); - - // Discord only exposes button *labels* (not URLs) via presence, so these - // are shown as plain (non-clickable) chips. - if (a.buttons && a.buttons.length) { - const bwrap = document.createElement("div"); - bwrap.className = "pc-buttons"; - a.buttons.forEach(function (label) { - const b = document.createElement("span"); - b.className = "pc-btn"; - b.textContent = typeof label === "string" ? label : (label && label.label) || "Open"; - bwrap.appendChild(b); - }); - row.appendChild(bwrap); + function customRow(a) { + const row = document.createElement("div"); + row.className = "pc-row pc-custom"; + const eu = emojiUrl(a.emoji); + row.innerHTML = + (eu ? '' + : '') + + '' + esc(a.state || "") + ""; + return row; } - return row; - } - function streamRow(a) { - const hasUrl = !!a.url; - const row = document.createElement(hasUrl ? "a" : "div"); - row.className = "pc-row pc-stream"; - if (hasUrl) { + function spotifyRow(s) { + const row = document.createElement("a"); + row.className = "pc-row pc-spotify"; row.target = "_blank"; row.rel = "noopener"; - row.href = a.url; - } - const platform = (a.url && /twitch/i.test(a.url)) ? "Twitch" - : (a.url && /youtube/i.test(a.url)) ? "YouTube" : "Live"; - row.innerHTML = - '' + - rowText("Streaming on " + platform, a.details || a.name || "", a.state || ""); - return row; - } - - // Discord display-name fonts (display_name_styles.font_id) -> our @font-face - // families in css/fonts.css. Only ids we have a look-alike for are mapped; - // any other id falls back to the card's normal font. (Comments = Discord's - // underlying font; "verify" = best-guess pairing with your file names.) - const NAME_FONTS = { - 3: "'DDN Sakura', cursive", // 3 CHERRY_BOMB - 4: "'DDN Jellybean', cursive", // 4 CHICLE - 6: "'DDN Modern', sans-serif", // 6 MUSEO_MODERNO - 7: "'DDN Medieval', serif", // 7 NEO_CASTEL - 8: "'DDN 8Bit', monospace", // 8 PIXELIFY - 10: "'DDN Vampyre', serif", // 10 SINISTRE - 11: "'DDN gg sans', sans-serif", // 11 DEFAULT (Discord's normal font) - 12: "'DDN Tempo', serif", // 12 ZILLA_SLAB - }; - - // ---- render ------------------------------------------------------------- - function render(d) { - if (!d) return; - latest = d; - - const u = d.discord_user || {}; - const status = d.discord_status || "offline"; - card.dataset.status = status; - if (statusTextEl) statusTextEl.textContent = STATUS_TITLE[status] || "Offline"; - - avImg.src = avatarUrl(u); - const deco = u.avatar_decoration_data; - if (deco && deco.asset) { - // Load decorations straight from Discord's CDN: they're animated APNGs, - // and the wsrv webp proxy fails on them (and would drop the animation). - avDeco.src = `https://cdn.discordapp.com/avatar-decoration-presets/${deco.asset}.png`; - avDeco.hidden = false; - } else { - avDeco.hidden = true; - } - nameEl.textContent = u.display_name || u.global_name || u.username || "Discord User"; - userEl.textContent = u.username ? "@" + u.username : ""; - - const styles = u.display_name_styles; - if (styles && styles.colors && styles.colors.length) { - const cols = styles.colors.map(intToHex); - nameEl.style.backgroundImage = "linear-gradient(90deg, " + (cols.length === 1 ? cols[0] + "," + cols[0] : cols.join(", ")) + ")"; - nameEl.classList.add("is-gradient"); - } else { - nameEl.style.backgroundImage = ""; - nameEl.classList.remove("is-gradient"); - } - // Custom display-name font from Discord's font_id (falls back to the - // card's normal font when there's no style or no look-alike for that id). - nameEl.style.fontFamily = (styles && NAME_FONTS[styles.font_id]) || ""; - - const pg = u.primary_guild; - if (pg && pg.tag && pg.identity_enabled) { - const badge = guildBadgeUrl(pg); - tagEl.innerHTML = (badge ? '' : "") + - '' + esc(pg.tag) + ""; - tagEl.hidden = false; - } else { - tagEl.hidden = true; + row.href = s.track_id ? "https://open.spotify.com/track/" + s.track_id : "https://open.spotify.com/"; + if (s.album) row.title = (s.song || "") + " — " + s.album; + if (s.timestamps && s.timestamps.start) row.dataset.start = s.timestamps.start; + if (s.timestamps && s.timestamps.end) row.dataset.end = s.timestamps.end; + row.innerHTML = + (s.album_art_url ? '' : "") + + rowText("Listening to Spotify", s.song || "", s.artist || "", + '"); + return row; } - platformsEl.innerHTML = platformIcons(d); + // Generic activity row (type 0). Discord presence exposes no link for + // games or apps, so this renders as a non-clickable card. + function activityRow(a) { + const isCode = /visual studio code|vscode/i.test(a.name || ""); + const row = document.createElement("div"); + row.className = "pc-row pc-row--stack " + (isCode ? "pc-dev" : "pc-game"); + if (a.timestamps && a.timestamps.start) row.dataset.elapsedStart = a.timestamps.start; - lastFlags = u.public_flags || 0; - paintBadges(); + const large = a.assets && a.assets.large_image && assetUrl(a.application_id, a.assets.large_image); + const small = a.assets && a.assets.small_image && assetUrl(a.application_id, a.assets.small_image); + const iconHtml = large + ? '' + + '' + + (small ? '' : "") + + "" + : ''; - const loc = d.kv && d.kv.location; - if (loc) { - metaEl.innerHTML = '' + esc(loc); - metaEl.hidden = false; - } else { - metaEl.hidden = true; - } - - const acts = d.activities || []; - - sections.innerHTML = ""; - - // The custom status renders in the identity column, directly under the - // name, so its thought-bubble tail rises to the username (Discord-style) - // — rather than down in the activity list. - if (customNode) { customNode.remove(); customNode = null; } - const custom = acts.find((a) => a.type === 4); - if (custom && (custom.state || (custom.emoji && custom.emoji.id))) { - customNode = customRow(custom); - idEl.appendChild(customNode); - } - card.classList.toggle("has-custom", !!customNode); - - if (d.listening_to_spotify && d.spotify) { - sections.appendChild(spotifyRow(d.spotify)); - applyAccent(d.spotify.album_art_url); - } else { - resetAccent(); - } - - acts.filter((a) => a.type === 0).forEach((a) => sections.appendChild(activityRow(a))); - acts.filter((a) => a.type === 1).forEach((a) => sections.appendChild(streamRow(a))); - - card.classList.toggle("has-sections", sections.children.length > 0); - updateTimes(); - if (sections.querySelector("[data-start], [data-elapsed-start]")) startTicker(); - else stopTicker(); - - card.hidden = false; - } - - // ---- time tickers (progress bar + elapsed labels) ----------------------- - function updateTimes() { - const sp = sections.querySelector(".pc-spotify[data-start][data-end]"); - if (sp) { - const start = +sp.dataset.start, end = +sp.dataset.end; - if (end > start) { - const elapsed = clamp(Date.now() - start, 0, end - start); - const fill = sp.querySelector(".pc-fill"); - const cur = sp.querySelector(".pc-cur"); - const dur = sp.querySelector(".pc-dur"); - if (fill) fill.style.width = clamp((elapsed / (end - start)) * 100, 0, 100) + "%"; - if (cur) cur.textContent = fmt(elapsed); - if (dur) dur.textContent = fmt(end - start); + let kind = isCode ? "Coding" : "Playing " + (a.name || ""); + if (a.party && a.party.size && a.party.size.length === 2 && a.party.size[1]) { + kind += " · " + a.party.size[0] + " of " + a.party.size[1]; } + + const main = document.createElement("div"); + main.className = "pc-row-link"; + main.innerHTML = iconHtml + + rowText(kind, a.details || (isCode ? "" : a.name) || "", + a.state || (a.assets && a.assets.large_text) || "", + ''); + row.appendChild(main); + + // Discord only exposes button *labels* (not URLs) via presence, so these + // are shown as plain (non-clickable) chips. + if (a.buttons && a.buttons.length) { + const bwrap = document.createElement("div"); + bwrap.className = "pc-buttons"; + a.buttons.forEach(function (label) { + const b = document.createElement("span"); + b.className = "pc-btn"; + b.textContent = typeof label === "string" ? label : (label && label.label) || "Open"; + bwrap.appendChild(b); + }); + row.appendChild(bwrap); + } + return row; } - sections.querySelectorAll("[data-elapsed-start]").forEach((row) => { - const lbl = row.querySelector(".pc-row-elapsed"); - if (lbl) lbl.textContent = elapsedStr(+row.dataset.elapsedStart); - }); - } - function startTicker() { if (!ticker) ticker = setInterval(updateTimes, 1000); } - function stopTicker() { if (ticker) { clearInterval(ticker); ticker = null; } } - // ---- data source: Doughmination Restful API (sole source) --------------- - // Returns presence + full profile (incl. theme_colors + display_name_styles) - // in a single call. Lanyard + dstn.to were removed. - const SELF_BASE = "https://restful.doughmination.uk/v1/users/"; - const SELF_POLL_MS = opts.pollMs || 20000; // presence refresh cadence - let selfTimer = null; + function streamRow(a) { + const hasUrl = !!a.url; + const row = document.createElement(hasUrl ? "a" : "div"); + row.className = "pc-row pc-stream"; + if (hasUrl) { + row.target = "_blank"; + row.rel = "noopener"; + row.href = a.url; + } + const platform = (a.url && /twitch/i.test(a.url)) ? "Twitch" + : (a.url && /youtube/i.test(a.url)) ? "YouTube" : "Live"; + row.innerHTML = + '' + + rowText("Streaming on " + platform, a.details || a.name || "", a.state || ""); + return row; + } - // self-host shape -> the Lanyard-shaped object render() already understands - function mapSelfHostToLanyard(j) { - const u = (j.data && j.data.user) || {}; - const p = (j.data && j.data.presence) || {}; - const plat = p.platform || {}; - const dec = u.avatar_decoration; - const clan = u.clan; - return { - discord_user: { - id: u.id || DISCORD_USER_ID, - username: u.username, - global_name: u.global_name, - display_name: u.display_name, - avatar: u.avatar, - avatar_decoration_data: (dec && dec.asset) ? { asset: dec.asset } : null, - primary_guild: (clan && clan.tag) - ? { tag: clan.tag, identity_enabled: true, badge: clan.badge, identity_guild_id: clan.guild_id } - : null, - // carry the Nitro name styling through so render() can apply the - // gradient + custom font (font_id) — without this it never reaches it - display_name_styles: u.display_name_styles || null, - public_flags: u.public_flags || 0 - }, - discord_status: p.status || (p.online ? "online" : "offline"), - activities: p.activities || [], - listening_to_spotify: !!p.listening_to_spotify, - spotify: p.spotify || null, - active_on_discord_desktop: !!plat.desktop, - active_on_discord_mobile: !!plat.mobile, - active_on_discord_web: !!plat.web, - kv: {} + // Discord display-name fonts (display_name_styles.font_id) -> our @font-face + // families in css/fonts.css. Only ids we have a look-alike for are mapped; + // any other id falls back to the card's normal font. (Comments = Discord's + // underlying font; "verify" = best-guess pairing with your file names.) + const NAME_FONTS = { + 3: "'DDN Sakura', cursive", // 3 CHERRY_BOMB + 4: "'DDN Jellybean', cursive", // 4 CHICLE + 6: "'DDN Modern', sans-serif", // 6 MUSEO_MODERNO + 7: "'DDN Medieval', serif", // 7 NEO_CASTEL + 8: "'DDN 8Bit', monospace", // 8 PIXELIFY + 10: "'DDN Vampyre', serif", // 10 SINISTRE + 11: "'DDN gg sans', sans-serif", // 11 DEFAULT (Discord's normal font) + 12: "'DDN Tempo', serif", // 12 ZILLA_SLAB }; - } - function renderFromSelfHost(j) { - const u = (j.data && j.data.user) || {}; - render(mapSelfHostToLanyard(j)); - // badges arrive pre-resolved (same consumer as dstn badges) - if (Array.isArray(j.data.badges) && j.data.badges.length) { - dstnBadges = j.data.badges; + // ---- render ------------------------------------------------------------- + function render(d) { + if (!d) return; + latest = d; + + const u = d.discord_user || {}; + const status = d.discord_status || "offline"; + card.dataset.status = status; + if (statusTextEl) statusTextEl.textContent = STATUS_TITLE[status] || "Offline"; + + avImg.src = avatarUrl(u); + const deco = u.avatar_decoration_data; + if (deco && deco.asset) { + // Load decorations straight from Discord's CDN: they're animated APNGs, + // and the wsrv webp proxy fails on them (and would drop the animation). + avDeco.src = `https://cdn.discordapp.com/avatar-decoration-presets/${deco.asset}.png`; + avDeco.hidden = false; + } else { + avDeco.hidden = true; + } + nameEl.textContent = u.display_name || u.global_name || u.username || "Discord User"; + userEl.textContent = u.username ? "@" + u.username : ""; + + const styles = u.display_name_styles; + if (styles && styles.colors && styles.colors.length) { + const cols = styles.colors.map(intToHex); + nameEl.style.backgroundImage = "linear-gradient(90deg, " + (cols.length === 1 ? cols[0] + "," + cols[0] : cols.join(", ")) + ")"; + nameEl.classList.add("is-gradient"); + } else { + nameEl.style.backgroundImage = ""; + nameEl.classList.remove("is-gradient"); + } + // Custom display-name font from Discord's font_id (falls back to the + // card's normal font when there's no style or no look-alike for that id). + nameEl.style.fontFamily = (styles && NAME_FONTS[styles.font_id]) || ""; + + const pg = u.primary_guild; + if (pg && pg.tag && pg.identity_enabled) { + const badge = guildBadgeUrl(pg); + tagEl.innerHTML = (badge ? '' : "") + + '' + esc(pg.tag) + ""; + tagEl.hidden = false; + } else { + tagEl.hidden = true; + } + + platformsEl.innerHTML = platformIcons(d); + + lastFlags = u.public_flags || 0; paintBadges(); + + const loc = d.kv && d.kv.location; + if (loc) { + metaEl.innerHTML = '' + esc(loc); + metaEl.hidden = false; + } else { + metaEl.hidden = true; + } + + const acts = d.activities || []; + + sections.innerHTML = ""; + + // The custom status renders in the identity column, directly under the + // name, so its thought-bubble tail rises to the username (Discord-style) + // — rather than down in the activity list. + if (customNode) { customNode.remove(); customNode = null; } + const custom = acts.find((a) => a.type === 4); + if (custom && (custom.state || (custom.emoji && custom.emoji.id))) { + customNode = customRow(custom); + idEl.appendChild(customNode); + } + card.classList.toggle("has-custom", !!customNode); + + if (d.listening_to_spotify && d.spotify) { + sections.appendChild(spotifyRow(d.spotify)); + applyAccent(d.spotify.album_art_url); + } else { + resetAccent(); + } + + acts.filter((a) => a.type === 0).forEach((a) => sections.appendChild(activityRow(a))); + acts.filter((a) => a.type === 1).forEach((a) => sections.appendChild(streamRow(a))); + + card.classList.toggle("has-sections", sections.children.length > 0); + updateTimes(); + if (sections.querySelector("[data-start], [data-elapsed-start]")) startTicker(); + else stopTicker(); + + card.hidden = false; } - // Nitro profile gradient — straight from the self-hosted API now that it - // returns theme_colors (previously only the dstn.to fallback applied this) - if (Array.isArray(u.theme_colors)) applyProfileGradient(u.theme_colors); - // profile extras: banner rebuilt from the raw hash (dodges the animated - // .gif 415), plus bio / connections / pronouns straight from the API - applyBanner( - bannerUrl(u.id || DISCORD_USER_ID, u.banner), - (typeof u.accent_color === "number") ? intToHex(u.accent_color) : null - ); - renderBio(u.bio); - renderConnections(j.data.connected_accounts); - if (pronounsEl) { - if (u.pronouns) { pronounsEl.textContent = u.pronouns; pronounsEl.hidden = false; } - else pronounsEl.hidden = true; + + // ---- time tickers (progress bar + elapsed labels) ----------------------- + function updateTimes() { + const sp = sections.querySelector(".pc-spotify[data-start][data-end]"); + if (sp) { + const start = +sp.dataset.start, end = +sp.dataset.end; + if (end > start) { + const elapsed = clamp(Date.now() - start, 0, end - start); + const fill = sp.querySelector(".pc-fill"); + const cur = sp.querySelector(".pc-cur"); + const dur = sp.querySelector(".pc-dur"); + if (fill) fill.style.width = clamp((elapsed / (end - start)) * 100, 0, 100) + "%"; + if (cur) cur.textContent = fmt(elapsed); + if (dur) dur.textContent = fmt(end - start); + } + } + sections.querySelectorAll("[data-elapsed-start]").forEach((row) => { + const lbl = row.querySelector(".pc-row-elapsed"); + if (lbl) lbl.textContent = elapsedStr(+row.dataset.elapsedStart); + }); } - // wishlist: resolved Shop collectibles (null when the API couldn't load it). - // Keep the panel live if it's already open when fresh data arrives. - wishlistItems = Array.isArray(j.data.wishlist) ? j.data.wishlist : null; - if (card.classList.contains("show-wishlist")) renderWishlist(); - } + function startTicker() { if (!ticker) ticker = setInterval(updateTimes, 1000); } + function stopTicker() { if (ticker) { clearInterval(ticker); ticker = null; } } - function loadSelfHosted() { - return fetch(SELF_BASE + DISCORD_USER_ID, { cache: "no-store" }) - .then(function (r) { return r.ok ? r.json().catch(function () { return null; }) : null; }) - .then(function (j) { - // render whenever the API has the user; presence may be null (offline) - if (!j || !j.success || !j.data || !j.data.user) return false; - renderFromSelfHost(j); - return true; - }) - .catch(function () { return false; }); - } + // ---- data source: Doughmination Restful API (sole source) --------------- + // Returns presence + full profile (incl. theme_colors + display_name_styles) + // in a single call. Lanyard + dstn.to were removed. + const SELF_BASE = "https://restful.doughmination.uk/v1/users/"; + const SELF_POLL_MS = opts.pollMs || 20000; // presence refresh cadence + let selfTimer = null; - function pollSelfHost() { - if (!document.hidden) loadSelfHosted(); - } + // self-host shape -> the Lanyard-shaped object render() already understands + function mapSelfHostToLanyard(j) { + const u = (j.data && j.data.user) || {}; + const p = (j.data && j.data.presence) || {}; + const plat = p.platform || {}; + const dec = u.avatar_decoration; + const clan = u.clan; + return { + discord_user: { + id: u.id || DISCORD_USER_ID, + username: u.username, + global_name: u.global_name, + display_name: u.display_name, + avatar: u.avatar, + avatar_decoration_data: (dec && dec.asset) ? { asset: dec.asset } : null, + primary_guild: (clan && clan.tag) + ? { tag: clan.tag, identity_enabled: true, badge: clan.badge, identity_guild_id: clan.guild_id } + : null, + // carry the Nitro name styling through so render() can apply the + // gradient + custom font (font_id) — without this it never reaches it + display_name_styles: u.display_name_styles || null, + public_flags: u.public_flags || 0 + }, + discord_status: p.status || (p.online ? "online" : "offline"), + activities: p.activities || [], + listening_to_spotify: !!p.listening_to_spotify, + spotify: p.spotify || null, + active_on_discord_desktop: !!plat.desktop, + active_on_discord_mobile: !!plat.mobile, + active_on_discord_web: !!plat.web, + kv: {} + }; + } - // boot: poll the Doughmination Restful API (the only source now). ID-less - // placeholder cards (e.g. dead alts) keep their seeded look — no fetch. - if (DISCORD_USER_ID) { - loadSelfHosted(); - selfTimer = setInterval(pollSelfHost, SELF_POLL_MS); - } + function renderFromSelfHost(j) { + const u = (j.data && j.data.user) || {}; + render(mapSelfHostToLanyard(j)); + // badges arrive pre-resolved (same consumer as dstn badges) + if (Array.isArray(j.data.badges) && j.data.badges.length) { + dstnBadges = j.data.badges; + paintBadges(); + } + // Nitro profile gradient — straight from the self-hosted API now that it + // returns theme_colors (previously only the dstn.to fallback applied this) + if (Array.isArray(u.theme_colors)) applyProfileGradient(u.theme_colors); + // profile extras: banner rebuilt from the raw hash (dodges the animated + // .gif 415), plus bio / connections / pronouns straight from the API + applyBanner( + bannerUrl(u.id || DISCORD_USER_ID, u.banner), + (typeof u.accent_color === "number") ? intToHex(u.accent_color) : null + ); + renderBio(u.bio); + renderConnections(j.data.connected_accounts); + if (pronounsEl) { + if (u.pronouns) { pronounsEl.textContent = u.pronouns; pronounsEl.hidden = false; } + else pronounsEl.hidden = true; + } + // wishlist: resolved Shop collectibles (null when the API couldn't load it). + // Keep the panel live if it's already open when fresh data arrives. + wishlistItems = Array.isArray(j.data.wishlist) ? j.data.wishlist : null; + if (card.classList.contains("show-wishlist")) renderWishlist(); + } - document.addEventListener("visibilitychange", () => { - if (!document.hidden && latest) updateTimes(); - }); + function loadSelfHosted() { + return fetch(SELF_BASE + DISCORD_USER_ID, { cache: "no-store" }) + .then(function (r) { return r.ok ? r.json().catch(function () { return null; }) : null; }) + .then(function (j) { + // render whenever the API has the user; presence may be null (offline) + if (!j || !j.success || !j.data || !j.data.user) return false; + renderFromSelfHost(j); + return true; + }) + .catch(function () { return false; }); + } + + function pollSelfHost() { + if (!document.hidden) loadSelfHosted(); + } + + // boot: poll the Doughmination Restful API (the only source now). ID-less + // placeholder cards (e.g. dead alts) keep their seeded look — no fetch. + if (DISCORD_USER_ID) { + loadSelfHosted(); + selfTimer = setInterval(pollSelfHost, SELF_POLL_MS); + } + + document.addEventListener("visibilitychange", () => { + if (!document.hidden && latest) updateTimes(); + }); return card; } // ---- end createPresenceCard ---- @@ -829,7 +846,162 @@ // Expose the factory so other pages (e.g. /cool-people) can build cards. window.PresenceCard = createPresenceCard; - // Auto-mount the standalone card whenever its #discord placeholder exists, - // preserving the original /discord page behaviour exactly. - if (document.getElementById("discord")) createPresenceCard({}); + // ---- the owner's own DUID, hardcoded so #my-discord never needs a query + // string / data attribute to know who to show. ---------------------------- + var MY_DISCORD_USER_ID = "1464890289922641993"; + + // Auto-mount the standalone card whenever its placeholder exists. + // #my-discord is the current mount point (see homepage / discord page). + // #discord is kept for backwards compatibility with older markup. + var myMount = document.getElementById("my-discord"); + if (myMount) { + createPresenceCard({ mount: myMount, userId: MY_DISCORD_USER_ID }); + } else if (document.getElementById("discord")) { + createPresenceCard({}); + } +})(); + +(function friends() { + "use strict"; + + // Each friend is rendered as a full — but smaller — presence card, built by + // the shared factory above (window.PresenceCard). Cards pull live + // presence (status, activity, badges, banner, bio, connections, wishlist…) + // from the same Doughmination Restful API the main card uses. + // NOTE: now lives in the same file as the factory (formerly discord.js), + // so load order is no longer a concern — this IIFE just runs second. + + var FRIENDS = [ + { + title: "Fiancée", + members: [ + { name: "Aria", tier: "wife", discordId: "1305215902685597797", link: null } + ] + }, + { + title: "Close Friends", + members: [ + { name: "Ari", tier: "close", discordId: "1474568910736199825", link: "https://a.stupid.cat" }, + // { name: "Lilly", tier: "close", discordId: "908055723659898902", link: null }, // Currently commented out as she's blocked me and idk if I should keep her displayed "close-friends" + function slugify(str) { + return String(str == null ? "" : str) + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + } + + var make = window.PresenceCard; + if (typeof make !== "function") { + console.error("friends.js: window.PresenceCard is missing — load /js/discord.js before /js/friends.js"); + } + + // ---- render --------------------------------------------------------- + FRIENDS.forEach(function (group) { + var section = document.createElement("section"); + section.className = "section"; + section.id = slugify(group.title); // anchor target, e.g. #alts + // gg sans (Discord's font) as the default for the whole friends widget — + // group headers, subtitles and the cards inside — over the page's Comic Code. + section.style.fontFamily = "'DDN gg sans', sans-serif"; + + var h2 = document.createElement("h2"); + h2.className = "section-title"; + h2.textContent = group.title; + section.appendChild(h2); + + if (group.subtitle) { + var sub = document.createElement("p"); + sub.className = "section-subtitle"; + sub.textContent = group.subtitle; + section.appendChild(sub); + } + + var grid = document.createElement("div"); + grid.className = "friend-grid"; + + group.members.forEach(function (m) { + // placeholder slot — the factory replaces it with the finished card + var slot = document.createElement("div"); + grid.appendChild(slot); + + // Always show the friendly name; for ID-less (dead alt) entries that + // also have a stored `user`, show that as the @username sub-row too. + if (typeof make === "function") { + make({ + mount: slot, + userId: m.discordId || null, // null → static placeholder card (dead alts) + mini: true, // smaller styling + keeps page accent local + pollMs: FRIEND_POLL_MS, + tier: m.tier || null, + link: m.link || null, + fallbackName: m.name, // shown instantly + kept if the API has no data + fallbackUser: (!m.discordId && m.user) ? m.user : null, + fallbackImg: m.img || null + }); + } else { + // hard fallback: at least show the name if the factory didn't load + slot.className = "presence-card is-mini" + (m.tier ? " tier-" + m.tier : ""); + slot.dataset.status = "offline"; + slot.textContent = m.name; + } + }); + + section.appendChild(grid); + root.appendChild(section); + }); + + // ---- jump to anchor (sections are built after page load) ------------ + function scrollToHash() { + var id = (location.hash || "").slice(1); + if (!id) return; + var target = document.getElementById(id); + if (target) target.scrollIntoView(); + } + scrollToHash(); + window.addEventListener("hashchange", scrollToHash); })(); \ No newline at end of file diff --git a/js/friends.js b/js/friends.js deleted file mode 100644 index 6baf6dc..0000000 --- a/js/friends.js +++ /dev/null @@ -1,140 +0,0 @@ -(function friends() { - "use strict"; - - // Each friend is rendered as a full — but smaller — presence card, built by - // the shared factory in discord.js (window.PresenceCard). Cards pull live - // presence (status, activity, badges, banner, bio, connections, wishlist…) - // from the same Doughmination Restful API the /discord card uses. - // NOTE: discord.js must be loaded BEFORE this file (see cool-people/index.html). - - var FRIENDS = [ - { - title: "Fiancée", - members: [ - { name: "Aria", tier: "wife", discordId: "1305215902685597797", link: null } - ] - }, - { - title: "Close Friends", - members: [ - // { name: "Lilly", tier: "close", discordId: "908055723659898902", link: null }, - { name: "Ria", tier: "close", discordId: "1513506390088618145", link: null }, - { name: "Camilla", tier: "close", discordId: "1110542429838397471", link: "https://cammy-the-cat.com" }, - { name: "Saphie", tier: "close", discordId: "527709099186716673", link: null }, - { name: "Ari", tier: "close", discordId: "1474568910736199825", link: "https://a.stupid.cat" } - ] - }, - { - title: "Friends", - members: [ - { name: "Fin", tier: "friend", discordId: "867818211574808607", link: null }, - { name: "Meme", tier: "friend", discordId: "812998699667161098", link: null }, - { name: "N", tier: "friend", discordId: "639399972407869450", link: null }, - { name: "Lylla", tier: "friend", discordId: "1009889543878611016", link: null }, - { name: "Simon", tier: "friend", discordId: "758466783354814514", link: null } - ] - }, - { - title: "Other Peeps", - subtitle: "You can request to be added here!", - members: [ - { name: "furi", tier: "known", discordId: "781445370177126401", link: "https://furina.is-a.dev"}, - { name: "pokemon", tier: "known", discordId: "784443338627612673", link: "https://devmatei.com/" } - ] - }, - { - title: "Alts", - subtitle: "My other accounts, dead or alive", - members: [ - { name: "J", img: "/assets/alts/j.png", tier: "active-alt", discordId: "1500197577336033301", link: null}, - { name: "Uzi", img: "/assets/alts/uzi.png", tier: "active-alt", discordId: "526626867973849123", link: null }, - { name: "clovetwilight3", img: "/assets/alts/clovetwilight3.png", tier: "dead-alt", discordId: null, link: null }, - { name: "estrogenhrt", img: "/assets/alts/estrogenhrt.png", tier: "dead-alt", discordId: null, link: null }, - { name: "Clove <3", img: "/assets/alts/clove.png", tier: "dead-alt", discordId: "1125844710511104030", link: null}, - { name: "Clove ⛤", img: "/assets/alts/butterfly.png", tier: "dead-alt", discordId: "514994021970739201", link: null }, - { name: "Mrow", img: "/assets/alts/mrow.png", tier: "dead-alt", discordId: "219480349053288450", link: null } - ] - } - ]; - - var FRIEND_POLL_MS = 60000; // re-poll each live friend once a minute - - var root = document.getElementById("friends-root"); - if (!root) return; - - // title → URL-safe anchor id, e.g. "Close Friends" -> "close-friends" - function slugify(str) { - return String(str == null ? "" : str) - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - } - - var make = window.PresenceCard; - if (typeof make !== "function") { - console.error("friends.js: window.PresenceCard is missing — load /js/discord.js before /js/friends.js"); - } - - // ---- render --------------------------------------------------------- - FRIENDS.forEach(function (group) { - var section = document.createElement("section"); - section.className = "section"; - section.id = slugify(group.title); // anchor target, e.g. #alts - // gg sans (Discord's font) as the default for the whole friends widget — - // group headers, subtitles and the cards inside — over the page's Comic Code. - section.style.fontFamily = "'DDN gg sans', sans-serif"; - - var h2 = document.createElement("h2"); - h2.className = "section-title"; - h2.textContent = group.title; - section.appendChild(h2); - - if (group.subtitle) { - var sub = document.createElement("p"); - sub.className = "section-subtitle"; - sub.textContent = group.subtitle; - section.appendChild(sub); - } - - var grid = document.createElement("div"); - grid.className = "friend-grid"; - - group.members.forEach(function (m) { - // placeholder slot — the factory replaces it with the finished card - var slot = document.createElement("div"); - grid.appendChild(slot); - - if (typeof make === "function") { - make({ - mount: slot, - userId: m.discordId || null, // null → static placeholder card (dead alts) - mini: true, // smaller styling + keeps page accent local - pollMs: FRIEND_POLL_MS, - tier: m.tier || null, - link: m.link || null, - fallbackName: m.name, // shown instantly + kept if the API has no data - fallbackImg: m.img || null - }); - } else { - // hard fallback: at least show the name if the factory didn't load - slot.className = "presence-card is-mini" + (m.tier ? " tier-" + m.tier : ""); - slot.dataset.status = "offline"; - slot.textContent = m.name; - } - }); - - section.appendChild(grid); - root.appendChild(section); - }); - - // ---- jump to anchor (sections are built after page load) ------------ - function scrollToHash() { - var id = (location.hash || "").slice(1); - if (!id) return; - var target = document.getElementById(id); - if (target) target.scrollIntoView(); - } - scrollToHash(); - window.addEventListener("hashchange", scrollToHash); -})(); diff --git a/js/fronting.js b/js/fronting.js index f574615..cf56959 100644 --- a/js/fronting.js +++ b/js/fronting.js @@ -31,8 +31,8 @@ card.setAttribute("aria-label", "Currently fronting"); card.innerHTML = '
' + - '' + - 'Currently fronting' + + '' + + 'Currently fronting' + '
' + '
'; mount.replaceWith(card); @@ -49,11 +49,11 @@ ? '' + esc(m.pronouns) + '' : ""; return '
' + - av + - '' + - '' + esc(name) + '' + - pronouns + - '' + + av + + '' + + '' + esc(name) + '' + + pronouns + + '' + '
'; } diff --git a/js/guestbook.js b/js/guestbook.js index fe514c1..ec6224f 100644 --- a/js/guestbook.js +++ b/js/guestbook.js @@ -152,7 +152,7 @@ if (global.turnstile && typeof global.turnstile.getResponse === "function") { return global.turnstile.getResponse() || ""; } - } catch (_) {} + } catch (_) { } // Fallback: the widget injects a hidden input named cf-turnstile-response var input = document.querySelector('[name="cf-turnstile-response"]'); return input ? input.value : ""; @@ -221,7 +221,7 @@ if (counterEl) counterEl.textContent = "0 / 500"; try { if (global.turnstile && global.turnstile.reset) global.turnstile.reset(); - } catch (_) {} + } catch (_) { } await loadEntries(); } catch (err) { console.error("[guestbook] submit failed", err); diff --git a/js/heatmap.js b/js/heatmap.js index 1522a5f..1a028ea 100644 --- a/js/heatmap.js +++ b/js/heatmap.js @@ -98,17 +98,17 @@ /* ---- pride palettes: [empty, level1, level2, level3, level4] ---- */ const N = "#20262e"; // shared neutral "empty" so lit cells pop on a dark card const THEMES = { - forest: ["#232a33", "#173f2c", "#1e7349", "#34ab68", "#5ce897"], - rainbow: [N, "#e40303", "#ff8c00", "#ffed00", "#2ecc40"], - trans: [N, "#5bcefa", "#f5a9b8", "#fbd3dc", "#ffffff"], - bi: [N, "#0038a8", "#7a4a99", "#c0277f", "#d60270"], - pan: [N, "#21b1ff", "#ffd800", "#ff8fc1", "#ff218c"], - lesbian: [N, "#d52d00", "#ff9a56", "#d362a4", "#a30262"], - nonbinary: [N, "#fcf434", "#b78fe0", "#9c59d1", "#ffffff"], - ace: [N, "#5a5a5a", "#a3a3a3", "#cf9fe6", "#800080"], + forest: ["#232a33", "#173f2c", "#1e7349", "#34ab68", "#5ce897"], + rainbow: [N, "#e40303", "#ff8c00", "#ffed00", "#2ecc40"], + trans: [N, "#5bcefa", "#f5a9b8", "#fbd3dc", "#ffffff"], + bi: [N, "#0038a8", "#7a4a99", "#c0277f", "#d60270"], + pan: [N, "#21b1ff", "#ffd800", "#ff8fc1", "#ff218c"], + lesbian: [N, "#d52d00", "#ff9a56", "#d362a4", "#a30262"], + nonbinary: [N, "#fcf434", "#b78fe0", "#9c59d1", "#ffffff"], + ace: [N, "#5a5a5a", "#a3a3a3", "#cf9fe6", "#800080"], genderfluid: [N, "#ff75a2", "#be18d6", "#333ebd", "#ffffff"], genderqueer: [N, "#4a8123", "#7bbf4f", "#b57edc", "#ffffff"], - agender: [N, "#8a8a8a", "#b9b9b9", "#b8f483", "#ffffff"], + agender: [N, "#8a8a8a", "#b9b9b9", "#b8f483", "#ffffff"], }; function resolveTheme(theme) { @@ -118,7 +118,7 @@ return THEMES[theme] || THEMES.rainbow; } - const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; // track in-flight fetch per container so re-renders cancel cleanly const controllers = new WeakMap(); @@ -160,7 +160,7 @@ if (palette[i]) root.style.setProperty("--contrib-" + i, palette[i]); } if (opts.colors) { - for (const k of [0,1,2,3,4]) { + for (const k of [0, 1, 2, 3, 4]) { if (opts.colors[k]) root.style.setProperty("--contrib-" + k, opts.colors[k]); } } diff --git a/js/music.js b/js/music.js index 12a52e5..f1ae10c 100644 --- a/js/music.js +++ b/js/music.js @@ -503,12 +503,12 @@ const artist = t.artist && (t.artist["#text"] || t.artist.name) || ""; return '
  • ' + '' + - (art ? '' - : '') + - '' + - '' + esc(t.name) + "" + - '' + esc(artist) + "" + - "" + when + + (art ? '' + : '') + + '' + + '' + esc(t.name) + "" + + '' + esc(artist) + "" + + "" + when + "
  • "; }).join(""); } catch (e) { @@ -618,12 +618,12 @@ topBox.innerHTML = '

    Top artists · last 7 days

    ' + '
      ' + arr.map((a, i) => '
    1. ' + - '' + (i + 1) + "" + - '' + - '' + - '' + esc(a.name) + "" + - '' + esc(a.playcount) + " plays" + - "" + + '' + (i + 1) + "" + + '' + + '' + + '' + esc(a.name) + "" + + '' + esc(a.playcount) + " plays" + + "" + "
    2. ").join("") + "
    "; // chips are already visible — fill in the artist images as they resolve const chips = topBox.querySelectorAll(".top-chip"); diff --git a/js/visitor-counter.js b/js/visitor-counter.js index b0784ad..785891c 100644 --- a/js/visitor-counter.js +++ b/js/visitor-counter.js @@ -75,7 +75,7 @@ "vc:" + namespace + ":" + key, JSON.stringify({ count, session: token }) ); - } catch (_) {} + } catch (_) { } } async function fetchCount(namespace, key) { diff --git a/music/index.html b/music/index.html index 9c52513..c964ba8 100644 --- a/music/index.html +++ b/music/index.html @@ -21,7 +21,8 @@ - + @@ -39,7 +40,8 @@ - + @@ -48,7 +50,8 @@ - + @@ -82,7 +85,8 @@ - +
    Connecting… @@ -99,9 +103,13 @@

    Lyrics

    - + +
    +
    +

    Waiting for a track…

    -

    Waiting for a track…

    Recently played

    @@ -109,7 +117,7 @@ - + diff --git a/projects/index.html b/projects/index.html index 9049cc4..5923db4 100644 --- a/projects/index.html +++ b/projects/index.html @@ -10,7 +10,8 @@ - + @@ -28,7 +29,8 @@ - + @@ -37,7 +39,8 @@ - + @@ -89,7 +92,8 @@ Ghostwire Closed Source
    -

    The private security bot and brains behind everything major in Girls.

    +

    The private security bot and brains behind everything major in + Girls.

    @@ -100,7 +104,8 @@ GayBot Open Source -

    A Discord bot for LGBTQIA+ servers — keyword emoji reactions, identity profiles, lookups, and a few fun extras.

    +

    A Discord bot for LGBTQIA+ servers — keyword emoji reactions, + identity profiles, lookups, and a few fun extras.

    @@ -140,7 +145,8 @@ Mod Update Checker Open Source -

    Add a short description for Mod Update Checker here.

    +

    Add a short description for Mod Update Checker here. +

    @@ -162,7 +168,8 @@ Widget-v2 Script Open Source -

    Add a short description for Widget-v2 Script here.

    +

    Add a short description for Widget-v2 Script here. +

    diff --git a/selfies/index.html b/selfies/index.html index d608a02..0948b7a 100644 --- a/selfies/index.html +++ b/selfies/index.html @@ -1,5 +1,6 @@ + @@ -48,6 +49,7 @@ +