فهرست منبع

ram spotify and other

simo 2 هفته پیش
والد
کامیت
18b882c524

+ 14 - 0
.claude/settings.json

@@ -0,0 +1,14 @@
+{
+  "permissions": {
+    "allow": [
+      "mcp__kb__kb_context",
+      "mcp__kb__kb_diff",
+      "mcp__kb__kb_draft",
+      "mcp__kb__kb_list",
+      "mcp__kb__kb_log",
+      "mcp__kb__kb_read",
+      "mcp__kb__kb_search",
+      "mcp__kb__kb_show"
+    ]
+  }
+}

+ 3 - 0
.codex/config.toml

@@ -0,0 +1,3 @@
+[mcp_servers.kb]
+command = "kb"
+args = ["mcp"]

+ 4 - 0
.kb-context.md

@@ -0,0 +1,4 @@
+<!-- KB managed: ~/knowledge-base/projects/simo2/context.md -->
+<!-- Always edit THIS file for project context. Do NOT edit the repo's CLAUDE.md. -->
+
+# simo2

+ 10 - 0
.mcp.json

@@ -0,0 +1,10 @@
+{
+  "mcpServers": {
+    "kb": {
+      "args": [
+        "mcp"
+      ],
+      "command": "kb"
+    }
+  }
+}

+ 1 - 0
CLAUDE.md

@@ -0,0 +1 @@
+@~/knowledge-base/projects/simo2/context.md

+ 2 - 2
astro.config.mjs

@@ -1,10 +1,10 @@
 import { defineConfig } from "astro/config";
-import tailwind from "@astrojs/tailwind";
+import mdx from "@astrojs/mdx";
 
 import sitemap from "@astrojs/sitemap";
 
 // https://astro.build/config
 export default defineConfig({
 	site: "https://simo.ng",
-	integrations: [tailwind(), sitemap()],
+	integrations: [mdx(), sitemap()],
 });

+ 11 - 0
data/spotify-last-known.json

@@ -0,0 +1,11 @@
+{
+  "title": "Under Pressure - Remastered 2011",
+  "artist": "Queen, David Bowie",
+  "album": "Hot Space (Deluxe Remastered Version)",
+  "albumArtUrl": "https://i.scdn.co/image/ab67616d0000b273a1e05e1048e2cf2737adf742",
+  "trackUrl": "https://open.spotify.com/track/11IzgLRXV7Cgek3tEgGgjw",
+  "durationMs": 248440,
+  "stoppedAtMs": 1776291222996,
+  "stoppedProgressMs": 4603,
+  "lastUpdatedAtMs": 1776291216786
+}

+ 10 - 0
opencode.json

@@ -0,0 +1,10 @@
+{
+  "$schema": "https://opencode.ai/config.json",
+  "mcp": {
+    "kb": {
+      "type": "local",
+      "command": ["kb"],
+      "enabled": true
+    }
+  }
+}

+ 5 - 4
package.json

@@ -10,17 +10,18 @@
     "astro": "astro"
   },
   "dependencies": {
+    "@astrojs/mdx": "^5.0.3",
     "@astrojs/sitemap": "^3.2.0",
-    "@astrojs/svelte": "^4.0.0",
-    "@astrojs/tailwind": "^5.1.0",
-    "astro": "^3.0.2",
-    "svelte": "^4.2.0",
+    "@astrojs/svelte": "^8.0.5",
+    "astro": "^6.1.6",
+    "svelte": "^5.55.4",
     "tailwindcss": "^3.3.3",
     "three": "^0.182.0",
     "typescript": "^5.3.3",
     "vite": "^5.0.11"
   },
   "devDependencies": {
+    "autoprefixer": "^10.5.0",
     "postcss": "^8.4.33",
     "postcss-nesting": "^12.0.4",
     "prettier": "^3.0.3",

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 365 - 362
pnpm-lock.yaml


+ 2 - 0
pnpm-workspace.yaml

@@ -0,0 +1,2 @@
+ignoredBuiltDependencies:
+  - core-js

+ 6 - 0
postcss.config.cjs

@@ -0,0 +1,6 @@
+module.exports = {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+};

BIN
public/fonts/departure-mono/DepartureMono-Regular.woff2


+ 1 - 1
src/components/duolingo.astro

@@ -39,7 +39,7 @@ import duolingoImg from "../assets/dualingo.png";
 	const styleElement = document.createElement('style');
 	styleElement.textContent = `
 		#duolingo.window {
-			font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
+			font-family: inherit;
 			background-color: #c0c0c0;
 			border: 2px solid;
 			border-color: #ffffff #808080 #808080 #ffffff;

+ 1 - 1
src/components/githubgraph.astro

@@ -22,7 +22,7 @@
 
 <style>
 	#github-graph.window {
-		font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
+		font-family: inherit;
 		background-color: #c0c0c0;
 		border: 2px solid;
 		border-color: #ffffff #808080 #808080 #ffffff;

+ 48 - 22
src/components/nowplaying.astro

@@ -27,10 +27,12 @@ const layout = {
       </div>
     </div>
 
-    <div class="np-controls">
+  <div class="np-controls">
       <button id="np-open" disabled>Open</button>
       <button id="np-refresh">Refresh</button>
     </div>
+
+    <div class="np-hint" id="np-hint"></div>
   </div>
 </Win98Window>
 
@@ -129,7 +131,9 @@ const layout = {
 </style>
 
 <script>
-  const apiUrl = "/api/spotify/now-playing.json";
+  // Static-site mode: point this at your external spotify-last-known service.
+  // Example: https://spotify-last-known.yourdomain.tld/last-known
+  const apiUrl = import.meta.env.PUBLIC_SPOTIFY_LAST_KNOWN_URL || "https://spotify.simo.ng/last-known";
   const pollMs = 8000;
 
   const elTitle = document.getElementById("np-title");
@@ -142,6 +146,7 @@ const layout = {
   const btnOpen = document.getElementById("np-open");
   const btnRefresh = document.getElementById("np-refresh");
 
+
   let lastTrackUrl = null;
   let timer = null;
   let tickTimer = null;
@@ -155,6 +160,7 @@ const layout = {
     durationMs: null,
     stoppedAtMs: null,
     stoppedProgressMs: null,
+    lastUpdatedAtMs: null,
   };
 
   let lastProgressMs = null;
@@ -163,6 +169,8 @@ const layout = {
   let lastTrackKey = null;
   let lastSyncAt = 0;
 
+  // Server now persists last-known track; we intentionally do not use localStorage.
+
   function fmtTime(ms) {
     if (typeof ms !== "number" || !Number.isFinite(ms)) return "--:--";
     const s = Math.max(0, Math.floor(ms / 1000));
@@ -192,6 +200,26 @@ const layout = {
     }
   }
 
+  function renderLastKnown() {
+    if (!lastKnown.title) return;
+    elTitle.textContent = `Last played: ${lastKnown.title}`;
+    elArtist.textContent = lastKnown.artist || "";
+
+    const stoppedWhen = fmtRelative(lastKnown.stoppedAtMs);
+    const updatedWhen = fmtRelative(lastKnown.lastUpdatedAtMs);
+    const suffix =
+      (stoppedWhen ? `stopped ${stoppedWhen}` : "") +
+      (stoppedWhen && updatedWhen ? `, updated ${updatedWhen}` : updatedWhen ? `updated ${updatedWhen}` : "");
+
+    elAlbum.textContent =
+      (lastKnown.album || "") + (suffix ? ` (${suffix})` : "");
+
+    setArt(lastKnown.albumArtUrl);
+    setProgress(lastKnown.stoppedProgressMs, lastKnown.durationMs);
+    lastTrackUrl = lastKnown.trackUrl;
+    if (btnOpen instanceof HTMLButtonElement) btnOpen.disabled = !lastTrackUrl;
+  }
+
   function setProgress(progressMs, durationMs) {
     if (typeof progressMs !== "number" || typeof durationMs !== "number" || durationMs <= 0) {
       elFill.style.width = "0%";
@@ -234,18 +262,19 @@ const layout = {
       const res = await fetch(apiUrl, { cache: "no-store" });
       const data = await res.json();
 
-      if (!data || data.isPlaying === false || !data.title) {
-        if (lastKnown.title) {
-          elTitle.textContent = `Last played: ${lastKnown.title}`;
-          elArtist.textContent = lastKnown.artist || "";
-          const when = fmtRelative(lastKnown.stoppedAtMs);
-          elAlbum.textContent = when
-            ? `${lastKnown.album || ""} (${when})`
-            : lastKnown.album || "";
-          setArt(lastKnown.albumArtUrl);
-          setProgress(lastKnown.stoppedProgressMs, lastKnown.durationMs);
-          lastTrackUrl = lastKnown.trackUrl;
-          if (btnOpen instanceof HTMLButtonElement) btnOpen.disabled = !lastTrackUrl;
+      // External service returns the last-known state directly.
+      const normalized =
+        data && typeof data === "object" && ("title" in data || "lastUpdatedAtMs" in data)
+          ? { isPlaying: false, lastKnown: data }
+          : data;
+
+      if (!normalized || normalized.isPlaying === false || !normalized.title) {
+        // If we just transitioned from playing -> not playing, capture stop time.
+        const serverLastKnown = normalized?.lastKnown;
+        if (serverLastKnown && serverLastKnown.title) {
+          // Keep whatever we last saw playing as a fallback, but prefer server state.
+          lastKnown = { ...lastKnown, ...serverLastKnown };
+          renderLastKnown();
         } else {
           elTitle.textContent = "(Nothing playing)";
           elArtist.textContent = "Spotify";
@@ -258,17 +287,12 @@ const layout = {
 
         stopTicking();
 
-        if (lastIsPlaying && lastKnown.title && !lastKnown.stoppedAtMs) {
-          lastKnown.stoppedAtMs = Date.now();
-          if (typeof lastProgressMs === "number") lastKnown.stoppedProgressMs = lastProgressMs;
-        }
-
         lastIsPlaying = false;
         lastProgressMs = null;
         lastDurationMs = null;
         lastTrackKey = null;
         lastSyncAt = 0;
-        elHint.textContent = data?.error ? `Status: ${data.error}` : "";
+        if (elHint) elHint.textContent = normalized?.error ? `Status: ${normalized.error}` : "";
         return;
       }
 
@@ -277,6 +301,7 @@ const layout = {
       elAlbum.textContent = data.album || "";
       setArt(data.albumArtUrl);
 
+      // Update the in-memory "last known" track (server persists it).
       lastKnown = {
         title: data.title ?? null,
         artist: data.artist ?? null,
@@ -286,6 +311,7 @@ const layout = {
         durationMs: typeof data.durationMs === "number" ? data.durationMs : null,
         stoppedAtMs: null,
         stoppedProgressMs: null,
+        lastUpdatedAtMs: Date.now(),
       };
 
       const trackKey = `${data.title}::${data.artist}::${data.album}`;
@@ -311,9 +337,9 @@ const layout = {
 
       lastTrackUrl = data.trackUrl || null;
       if (btnOpen instanceof HTMLButtonElement) btnOpen.disabled = !lastTrackUrl;
-      elHint.textContent = "";
+      if (elHint) elHint.textContent = "";
     } catch (e) {
-      elHint.textContent = "Unable to load now playing.";
+      if (elHint) elHint.textContent = "Unable to load now playing.";
     }
   }
 

+ 19 - 0
src/components/ram.astro

@@ -0,0 +1,19 @@
+---
+import Win98Window from "./win98window.astro";
+
+const layout = {
+	mobile: { left: "2%", top: "10%", width: "96%", height: "70vh" },
+	desktop: { left: "12%", top: "12%", width: "min(900px, 92vw)", height: "min(620px, 70vh)" },
+};
+---
+
+<Win98Window title="ram" layout={layout} shown={false}>
+	<div style="height: calc(100% - 26px); padding: 0;">
+		<iframe
+			id="ram-frame"
+			title="RAM"
+			src="/ram/embed"
+			style="width: 100%; height: 100%; border: 0; outline: 0; background: white;"
+		></iframe>
+	</div>
+</Win98Window>

+ 14 - 0
src/content.config.ts

@@ -0,0 +1,14 @@
+import { defineCollection, z } from "astro:content";
+import { glob } from "astro/loaders";
+
+const blogs = defineCollection({
+  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blogs" }),
+  schema: z.object({
+    title: z.string(),
+    pubDate: z.coerce.date(),
+    description: z.string().optional(),
+    draft: z.boolean().optional(),
+  }),
+});
+
+export const collections = { blogs };

+ 5 - 47
src/content/blogs/pgpcord.md

@@ -1,4 +1,8 @@
-Tiny Rocket Blog
+---
+title: "PGP + Discord = ??"
+pubDate: 2025-10-24
+description: "A prototype extension (PGPCord) for sending PGP-encrypted messages over Discord."
+---
 PGP + Discord = ??
 
 Table of Contents
@@ -9,9 +13,6 @@ Table of Contents
 - Design Goals
 - Methodology
 - Implementation Notes
-- Potential Problems
-- Next Steps
-- Appendix: Quick Usage
 
 TL;DR
 I built a prototype extension (PGPCord) that lets you send PGP-encrypted messages over Discord by encrypting payloads client-side before they're posted. It demonstrates a practical way to layer end‑to‑end encryption on top of an untrusted platform. There are important limitations and attack surfaces — this is proof of concept, not a battle-tested privacy product.
@@ -51,46 +52,3 @@ Methodology
 4. Fallback:
    - If a recipient lacks a public key in their local address book, fall back to the plaintext send (or warn).
    - Provide a way to send a "public key request" message to a user.
-
-Implementation Notes
-- Interception methods vary by platform and client:
-  - Web client: content scripts can modify the compose box, listen for submit events, and change outgoing payloads.
-  - Desktop clients: depends on extensibility (Electron clients might be patchable, native clients are harder).
-- Cryptography:
-  - Use a modern OpenPGP implementation (OpenPGP.js or similar).
-  - Use reasonable defaults for key size and algorithms, but allow user control.
-  - Protect private keys with a passphrase and, when possible, integrate with WebCrypto for key wrapping.
-- UI:
-  - Simple indicators for encrypted messages (lock icon, origin public key fingerprint).
-  - Automatic decryption should be opt-in per user to avoid leaking metadata or surprising the user.
-
-Potential Problems
-- Interception before encryption: the extension must be able to intercept and encrypt messages before they are sent. If the platform or client modifies content after the extension runs (or the extension hooks into the wrong event), plaintext might leak.
-- Metadata leakage: while the message body can be encrypted, metadata (recipient, timestamps, channel names) stays visible to the platform.
-- Key discovery and authenticity: publishing a public key somewhere doesn't guarantee it's the right key for a given Discord user account — an attacker could publish a rogue key. You still need a way to verify key ownership (fingerprint verification, posting key on authoritative profile pages).
-- Usability: key handling, passphrases, and import/export are UX pain points; users may make mistakes (e.g., losing private keys).
-- Platform countermeasures: some clients may obfuscate or restrict script-based modification, breaking the interception approach.
-- Legal and policy: automating encryption in messages might run afoul of platform policies or moderation tools that rely on plaintext scanning.
-
-Next Steps
-- Harden interception points and test across desktop and web clients.
-- Add a simple key discovery flow (profile metadata, pastebin, or DNS TXT records pointing to a public key).
-- Improve private key security by integrating browser-native key stores where available.
-- Explore message formats that minimize accidental plaintext leaks (e.g., envelope with metadata encrypted) and robust detection heuristics.
-- Perform threat modeling and consider consequences if keys are exfiltrated or if an attacker controls the extension update mechanism.
-
-Appendix: Quick Usage (Prototype)
-1. Install the extension in your browser.
-2. Generate or import your PGP key within the extension and publish your public key where your contacts can find it.
-3. When composing a Discord message, select the recipient or channel and use the extension's encrypt action — your text will be replaced with an armored PGP block before send.
-4. When you receive an armored block, the extension will attempt to decrypt and render it inline.
-
-Closing thoughts
-This approach is a pragmatic way to add end‑to‑end confidentiality on top of an existing platform without requiring everyone to switch services. It doesn't eliminate all risks, and it's not a drop-in replacement for a secure messaging app, but it can be a useful bridge while preserving familiar workflows. The biggest practical problems are interception timing, key authenticity, and handling metadata — if you can accept those constraints, PGPCord-style tooling can meaningfully reduce exposure of message plaintext to untrusted servers.
-
-If you want, I can:
-- Produce the extension skeleton (OpenPGP.js + content script hooks for Discord web),
-- Implement a robust key import/export UI,
-- Or adapt the design into a small demo you can run locally.
-
-(End of post)

+ 29 - 0
src/layouts/DocumentLayout.astro

@@ -0,0 +1,29 @@
+---
+import Layout from "./Layout.astro";
+
+interface Props {
+	title: string;
+	subtitle?: string;
+}
+
+const { title, subtitle } = Astro.props;
+
+import "../styles/ram.css";
+---
+
+<Layout title={title}>
+	<div class="ram-page">
+		<div class="ram-document window">
+			<div class="title-bar">
+				<div class="title-bar-text">{title}</div>
+				<div class="title-bar-controls">
+					<a class="ram-close" href="/" aria-label="Close"></a>
+				</div>
+			</div>
+			<div class="window-body ram-body">
+				{subtitle && <p class="ram-subtitle">{subtitle}</p>}
+				<slot />
+			</div>
+		</div>
+	</div>
+</Layout>

+ 2 - 1
src/layouts/Layout.astro

@@ -4,6 +4,7 @@ interface Props {
 }
 
 const { title } = Astro.props;
+import { ViewTransitions } from "astro:transitions";
 import "../styles/98.css"
 ---
 
@@ -33,7 +34,7 @@ import "../styles/98.css"
 
 		image-rendering: pixelated;
 
-		font-family: "Pixelated MS Sans Serif", Arial !important;
+		font-family: "Departure Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
 	}
 	body, html {
 		background-color: #008080;

+ 79 - 0
src/lib/spotifyLastKnown.server.ts

@@ -0,0 +1,79 @@
+import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+
+export type SpotifyLastKnown = {
+	// Track identity
+	title: string | null;
+	artist: string | null;
+	album: string | null;
+	albumArtUrl: string | null;
+	trackUrl: string | null;
+
+	// Playback metadata
+	durationMs: number | null;
+	stoppedAtMs: number | null;
+	stoppedProgressMs: number | null;
+
+	// When the server last successfully synced this track from Spotify.
+	lastUpdatedAtMs: number | null;
+};
+
+const DEFAULT_STATE: SpotifyLastKnown = {
+	title: null,
+	artist: null,
+	album: null,
+	albumArtUrl: null,
+	trackUrl: null,
+	durationMs: null,
+	stoppedAtMs: null,
+	stoppedProgressMs: null,
+	lastUpdatedAtMs: null,
+};
+
+function coerceState(v: unknown): SpotifyLastKnown {
+	if (!v || typeof v !== "object") return { ...DEFAULT_STATE };
+	const o = v as Record<string, unknown>;
+	return {
+		title: typeof o.title === "string" ? o.title : null,
+		artist: typeof o.artist === "string" ? o.artist : null,
+		album: typeof o.album === "string" ? o.album : null,
+		albumArtUrl: typeof o.albumArtUrl === "string" ? o.albumArtUrl : null,
+		trackUrl: typeof o.trackUrl === "string" ? o.trackUrl : null,
+		durationMs: typeof o.durationMs === "number" ? o.durationMs : null,
+		stoppedAtMs: typeof o.stoppedAtMs === "number" ? o.stoppedAtMs : null,
+		stoppedProgressMs:
+			typeof o.stoppedProgressMs === "number" ? o.stoppedProgressMs : null,
+		lastUpdatedAtMs:
+			typeof o.lastUpdatedAtMs === "number" ? o.lastUpdatedAtMs : null,
+	};
+}
+
+function stateFilePath(): string {
+	// Workspace-local file for a single Node server.
+	// If you want to move it, set SPOTIFY_LAST_KNOWN_PATH.
+	const p = process.env.SPOTIFY_LAST_KNOWN_PATH;
+	return resolve(
+		process.cwd(),
+		p && p.trim() ? p : "data/spotify-last-known.json",
+	);
+}
+
+export async function readSpotifyLastKnown(): Promise<SpotifyLastKnown> {
+	const path = stateFilePath();
+	try {
+		const text = await readFile(path, "utf8");
+		return coerceState(JSON.parse(text));
+	} catch {
+		return { ...DEFAULT_STATE };
+	}
+}
+
+export async function writeSpotifyLastKnown(
+	next: SpotifyLastKnown,
+): Promise<void> {
+	const path = stateFilePath();
+	await mkdir(dirname(path), { recursive: true });
+	const tmp = `${path}.tmp`;
+	await writeFile(tmp, JSON.stringify(next, null, 2) + "\n", "utf8");
+	await rename(tmp, path);
+}

+ 1 - 1
src/pages/api/spotify/callback.json.ts

@@ -1,4 +1,4 @@
-export const prerender = false;
+export const prerender = true;
 
 const TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";
 

+ 3 - 1
src/pages/api/spotify/callback.ts

@@ -1,4 +1,6 @@
-export const prerender = false;
+// These endpoints are only used in dev / when deployed with an SSR adapter.
+// Marking them prerenderable keeps `astro build` working for static deploys.
+export const prerender = true;
 
 const TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";
 

+ 1 - 1
src/pages/api/spotify/login.ts

@@ -1,4 +1,4 @@
-export const prerender = false;
+export const prerender = true;
 
 export async function GET({ url }: { url: URL }) {
 	const client_id = import.meta.env.SPOTIFY_CLIENT_ID;

+ 50 - 3
src/pages/api/spotify/now-playing.json.ts

@@ -1,4 +1,10 @@
-export const prerender = false;
+export const prerender = true;
+
+import {
+	readSpotifyLastKnown,
+	writeSpotifyLastKnown,
+	type SpotifyLastKnown,
+} from "../../../lib/spotifyLastKnown.server";
 
 const NOW_PLAYING_ENDPOINT =
 	"https://api.spotify.com/v1/me/player/currently-playing";
@@ -80,6 +86,9 @@ function cachedAccessAccessTokenStillValid(
 
 export async function GET() {
 	try {
+		const prev = await readSpotifyLastKnown();
+		const nowMs = Date.now();
+
 		const accessToken = await getAccessToken();
 
 		const res = await fetch(NOW_PLAYING_ENDPOINT, {
@@ -90,7 +99,17 @@ export async function GET() {
 
 		// 204 No Content means nothing is currently playing
 		if (res.status === 204) {
-			return new Response(JSON.stringify({ isPlaying: false }), {
+			// Capture the stop time once (first time we observe "not playing").
+			// Do NOT overwrite lastUpdatedAtMs here: that should represent the last
+			// time we successfully synced while it was playing.
+			if (prev.title && !prev.stoppedAtMs) {
+				await writeSpotifyLastKnown({
+					...prev,
+					stoppedAtMs: nowMs,
+				});
+			}
+			const lastKnown = await readSpotifyLastKnown();
+			return new Response(JSON.stringify({ isPlaying: false, lastKnown }), {
 				status: 200,
 				headers: {
 					"Content-Type": "application/json; charset=utf-8",
@@ -103,7 +122,11 @@ export async function GET() {
 			// Token might be expired; clear cache and let client retry on next poll.
 			cachedAccessToken = null;
 			return new Response(
-				JSON.stringify({ isPlaying: false, error: "unauthorized" }),
+				JSON.stringify({
+					isPlaying: false,
+					error: "unauthorized",
+					lastKnown: prev,
+				}),
 				{
 					status: 200,
 					headers: {
@@ -121,6 +144,7 @@ export async function GET() {
 					isPlaying: false,
 					error: `spotify_${res.status}`,
 					detail: text,
+					lastKnown: prev,
 				}),
 				{
 					status: 200,
@@ -150,6 +174,28 @@ export async function GET() {
 			trackUrl: item?.external_urls?.spotify ?? null,
 		};
 
+		// Persist last-known track server-side.
+		const nextLastKnown: SpotifyLastKnown = {
+			title: payload.title,
+			artist: payload.artist,
+			album: payload.album,
+			albumArtUrl: payload.albumArtUrl,
+			trackUrl: payload.trackUrl,
+			durationMs:
+				typeof payload.durationMs === "number" ? payload.durationMs : null,
+			stoppedAtMs: payload.isPlaying ? null : (prev.stoppedAtMs ?? nowMs),
+			stoppedProgressMs:
+				payload.isPlaying === false
+					? typeof payload.progressMs === "number"
+						? payload.progressMs
+						: prev.stoppedProgressMs
+					: null,
+			// Only update the "last updated" timestamp while playing.
+			// When playback stops, we want the UI to show when it *stopped being updated*.
+			lastUpdatedAtMs: payload.isPlaying ? nowMs : prev.lastUpdatedAtMs,
+		};
+		await writeSpotifyLastKnown(nextLastKnown);
+
 		return new Response(JSON.stringify(payload), {
 			status: 200,
 			headers: {
@@ -164,6 +210,7 @@ export async function GET() {
 				isPlaying: false,
 				error: "server_error",
 				detail: message,
+				lastKnown: await readSpotifyLastKnown().catch(() => null),
 			}),
 			{
 				status: 200,

+ 23 - 48
src/pages/index.astro

@@ -11,6 +11,7 @@ import Projects from "../components/projects.astro";
 import Duolingo from "../components/duolingo.astro";
 import GitHubGraph from "../components/githubgraph.astro";
 import NowPlaying from "../components/nowplaying.astro";
+import Ram from "../components/ram.astro";
 
 // Projects
 import Summize from "../components/projects/summize.astro";
@@ -28,6 +29,8 @@ import contact from "../assets/apps/contact.png";
 import pgp from "../assets/apps/pgp.png";
 import Glance from "../components/projects/glance.astro";
 
+import ramIcon from "../assets/apps/blog.png";
+
 function dragElement(elmnt) {
 	var pos1 = 0,
 		pos2 = 0,
@@ -35,11 +38,9 @@ function dragElement(elmnt) {
 		pos4 = 0;
 
 	if (document.getElementById(elmnt.id + "header")) {
-		// If present, the header is where you move the DIV from:
 		document.getElementById(elmnt.id + "header").onmousedown = dragMouseDown;
 		document.getElementById(elmnt.id + "header").ontouchstart = dragMouseDown;
 	} else {
-		// Otherwise, move the DIV from anywhere inside the DIV:
 		elmnt.onmousedown = dragMouseDown;
 		elmnt.ontouchstart = dragMouseDown;
 	}
@@ -47,7 +48,6 @@ function dragElement(elmnt) {
 	function dragMouseDown(e) {
 		// e.preventDefault();
 		raiseUpSpec(elmnt.id);
-		// Get the initial touch point for touch events
 		if (e.type == "touchstart") {
 			pos3 = e.touches[0].clientX;
 			pos4 = e.touches[0].clientY;
@@ -64,25 +64,22 @@ function dragElement(elmnt) {
 	function elementDrag(e) {
 		// e.preventDefault();
 		if (e.type == "touchmove") {
-			// Calculate the new cursor position for touch
 			pos1 = pos3 - e.touches[0].clientX;
 			pos2 = pos4 - e.touches[0].clientY;
 			pos3 = e.touches[0].clientX;
 			pos4 = e.touches[0].clientY;
 		} else {
-			// Calculate the new cursor position for mouse
 			pos1 = pos3 - e.clientX;
 			pos2 = pos4 - e.clientY;
 			pos3 = e.clientX;
 			pos4 = e.clientY;
 		}
-		// Set the element's new position
+
 		elmnt.style.top = elmnt.offsetTop - pos2 + "px";
 		elmnt.style.left = elmnt.offsetLeft - pos1 + "px";
 	}
 
 	function closeDragElement() {
-		// Stop moving when mouse button or touch is released
 		document.onmouseup = null;
 		document.ontouchend = null;
 		document.onmousemove = null;
@@ -178,6 +175,15 @@ let apps: app[] = [
 		},
 		row: 2,
 	},
+	{
+		name: "RAM",
+		logo: ramIcon.src,
+		onclick: () => {
+			raiseUpSpec("ram");
+			document.getElementById("ram").style.display = "block";
+		},
+		row: 2,
+	},
 	{
 		name: "Contact",
 		logo: contact.src,
@@ -262,16 +268,17 @@ apps = apps.map((item) => {
 			referrerpolicy="no-referrer"
 			allowfullscreen
 			style="
-				display: block;
+				display: none;
 				position: absolute;
 				left: 62%;
 				top: 10%;
-				height: 420px;
 				border: 0;
 				outline: 0;
 				background: transparent;
 			"
 		></iframe>
+
+		<Ram />
 		<NowPlaying />
 		<Projects projects={projectsArr} />
 		<Summize />
@@ -346,6 +353,11 @@ apps = apps.map((item) => {
 	Array.from(document.getElementsByClassName("window")).forEach((window) => {
 		dragElement(window);
 
+		window.querySelectorAll('.title-bar-controls button').forEach((btn) => {
+			btn.addEventListener('mousedown', (e) => e.stopPropagation());
+			btn.addEventListener('touchstart', (e) => e.stopPropagation());
+		});
+
 		window.addEventListener("click", () => {
 			raiseUpSpec(window.id);
 		});
@@ -384,7 +396,6 @@ apps = apps.map((item) => {
 					};
 					window.addEventListener('animationend', onEnd, { once: true });
 
-					// If no CSS animation runs, ensure the window still closes.
 					setTimeout(() => {
 						if (window.style.display !== 'none') {
 							window.classList.remove('closed');
@@ -396,15 +407,6 @@ apps = apps.map((item) => {
 		});
 	});
 
-	// Make the SimoSearch iframe draggable too (borderless widget).
-	const simoSearch = document.getElementById('SimoSearch');
-	if (simoSearch) {
-		dragElement(simoSearch);
-		simoSearch.addEventListener('click', () => {
-			simoSearch.style.zIndex = '9999';
-		});
-	}
-
 	apps.forEach((app) => {
 		let itemName = app.name.replace(" ", "");
 
@@ -420,23 +422,18 @@ apps = apps.map((item) => {
 		});
 	});
 
-	// Minimize and restore window functions
 	function minimizeWindow(windowId) {
 		const window = document.getElementById(windowId);
 
-		// First add to taskbar to get the button position
 		addToTaskbar(windowId);
 
-		// Get window and target button positions
 		const windowRect = window.getBoundingClientRect();
 		const taskbarButton = document.getElementById(`taskbar-${windowId}`);
 		const buttonRect = taskbarButton.getBoundingClientRect();
 
-		// Calculate the translation needed
 		const translateX = buttonRect.left - windowRect.left;
 		const translateY = buttonRect.top - windowRect.top;
 
-		// Create custom animation
 		window.style.transition = "all 0.3s ease-out";
 		window.style.transform = `translate(${translateX}px, ${translateY}px) scale(0.1)`;
 		window.style.opacity = "0";
@@ -455,20 +452,16 @@ apps = apps.map((item) => {
 		const buttonRect = taskbarButton.getBoundingClientRect();
 		const windowRect = window.getBoundingClientRect();
 
-		// Calculate starting position
 		const translateX = buttonRect.left - windowRect.left;
 		const translateY = buttonRect.top - windowRect.top;
 
-		// Set initial state
 		window.style.transform = `translate(${translateX}px, ${translateY}px) scale(0.1)`;
 		window.style.opacity = "0";
 		window.style.display = "block";
 
-		// Remove from taskbar
 		removeFromTaskbar(windowId);
 		raiseUpSpec(windowId);
 
-		// Trigger animation after a frame
 		requestAnimationFrame(() => {
 			window.style.transition = "all 0.3s ease-out";
 			window.style.transform = "translate(0, 0) scale(1)";
@@ -485,7 +478,6 @@ apps = apps.map((item) => {
 	function addToTaskbar(windowId) {
 		const taskbarWindows = document.getElementById("taskbar-windows");
 
-		// Check if already in taskbar
 		if (document.getElementById(`taskbar-${windowId}`)) {
 			return;
 		}
@@ -494,20 +486,8 @@ apps = apps.map((item) => {
 		const titleBar = window.querySelector(".title-bar-text");
 		const title = titleBar ? titleBar.textContent : windowId;
 
-		// Get icon for the window
-		const iconMap = {
-			'aboutme': '👤',
-			'contact': '📧',
-			'pgp': '🔐',
-			'projects': '📁',
-			'duolingo': '🦉',
-			'redirect': '🔗',
-			'summize': '📝',
-			'notion': '📋',
-			'imdb': '🎬'
-		};
-		const icon = iconMap[windowId] || '📄';
-
+		const icon = "";
+		
 		const button = document.createElement("button");
 		button.id = `taskbar-${windowId}`;
 		button.style.cssText = `
@@ -525,7 +505,6 @@ apps = apps.map((item) => {
 			border: none;
 		`;
 
-		// Create icon span
 		const iconSpan = document.createElement("span");
 		iconSpan.textContent = icon;
 		iconSpan.style.cssText = `
@@ -534,7 +513,6 @@ apps = apps.map((item) => {
 			line-height: 1;
 		`;
 
-		// Create text span
 		const textSpan = document.createElement("span");
 		textSpan.textContent = title;
 		textSpan.style.cssText = `
@@ -549,7 +527,6 @@ apps = apps.map((item) => {
 		button.appendChild(iconSpan);
 		button.appendChild(textSpan);
 
-		// Add active state styling
 		button.addEventListener("mousedown", () => {
 			button.style.boxShadow = "-2px -2px #818181, -2px 0 #818181, 0 -2px #818181, -4px -4px black, -4px 0 black, 0 -4px black, 2px 2px #e0dede, 0 2px #e0dede, 2px 0 #e0dede, 2px -2px #818181, -2px 2px #e0dede, -4px 2px black, -4px 4px white, 4px 4px white, 4px 0 white, 0 4px white, 2px -4px black, 4px -4px white";
 		});
@@ -572,14 +549,12 @@ apps = apps.map((item) => {
 		}
 	}
 
-	// Start button: bring SimoSearch to front (and show it if closed).
 	const startButton = document.querySelector('button[aria-label="startButton"]');
 	if (startButton) {
 		startButton.addEventListener('click', () => {
 			const w = document.getElementById('SimoSearch');
 			if (!w) return;
 			w.style.display = 'block';
-			// SimoSearch is an iframe, not a Win98 window.
 			w.style.zIndex = '9999';
 		});
 	}

+ 47 - 0
src/pages/ram/[slug].astro

@@ -0,0 +1,47 @@
+---
+import { getCollection, render } from "astro:content";
+import DocumentLayout from "../../layouts/DocumentLayout.astro";
+
+// In static builds, query params are not available at build time.
+// Use a dedicated embed route instead of `?embed=1`.
+const embed = false;
+
+export async function getStaticPaths() {
+	const posts = await getCollection("blogs");
+	return posts
+		.filter((p) => (import.meta.env.PROD ? !p.data.draft : true))
+		.map((post) => ({ params: { slug: post.id } }));
+}
+
+const { slug } = Astro.params;
+const posts = await getCollection("blogs");
+const entry = posts.find((p) => p.id === slug);
+
+if (!entry) {
+	return new Response(null, { status: 404, statusText: "Not found" });
+}
+
+// `getCollection()` entries have a `render()` method at runtime.
+// The TS types can lag behind depending on Astro version/content pipeline.
+// Cast to `any` to avoid blocking builds.
+const { Content } = await render(entry);
+---
+
+{embed ? (
+	<article>
+		<Content />
+	</article>
+) : (
+	<DocumentLayout
+		title={entry.data.title}
+		subtitle={entry.data.pubDate.toLocaleDateString("en-US", {
+			year: "numeric",
+			month: "short",
+			day: "2-digit",
+		})}
+	>
+		<article>
+			<Content />
+		</article>
+	</DocumentLayout>
+)}

+ 220 - 0
src/pages/ram/embed.astro

@@ -0,0 +1,220 @@
+---
+import { getCollection } from "astro:content";
+
+const allPosts = await getCollection("blogs");
+const posts = allPosts
+	.filter((p) => (import.meta.env.PROD ? !p.data.draft : true))
+	.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
+---
+
+<div class="ram-embed" data-view="icons">
+	<header class="ram-embed__header">
+		<div class="ram-embed__path" aria-label="Path">
+			<span class="ram-embed__path-label">Address</span>
+			<span class="ram-embed__path-value">C:\RAM\Posts</span>
+		</div>
+	</header>
+
+	<main class="ram-embed__content" aria-label="Posts">
+		<div class="ram-embed__grid" role="list">
+			{posts.map((post) => (
+				<a
+					class="ram-embed__icon"
+					href={`/ram/embed/${post.id}`}
+					role="listitem"
+					data-id={post.id}
+				>
+					<span class="ram-embed__icon-art" aria-hidden="true" />
+					<span class="ram-embed__icon-title">{post.data.title}</span>
+					<span class="ram-embed__icon-meta">
+						{post.data.pubDate.toLocaleDateString("en-US", {
+							year: "numeric",
+							month: "short",
+							day: "2-digit",
+						})}
+					</span>
+				</a>
+			))}
+		</div>
+	</main>
+
+	<footer class="ram-embed__status" aria-label="Status">
+		<span>{posts.length} object{posts.length === 1 ? "" : "s"}</span>
+		<span class="ram-embed__status-hint">Tip: click an icon to open</span>
+	</footer>
+</div>
+
+<script>
+	// Lightweight icon selection (single-click highlights, double-click navigates).
+	const root = document.querySelector(".ram-embed");
+	if (root) {
+		const icons = Array.from(root.querySelectorAll(".ram-embed__icon"));
+		let selected = null;
+
+		function setSelected(el) {
+			if (selected) selected.removeAttribute("data-selected");
+			selected = el;
+			if (selected) selected.setAttribute("data-selected", "true");
+		}
+
+		for (const icon of icons) {
+			icon.addEventListener("click", (e: MouseEvent) => {
+				// Allow normal navigation on modified clicks.
+				if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+				e.preventDefault();
+				setSelected(icon);
+			});
+
+			icon.addEventListener("dblclick", () => {
+				window.location.href = icon.getAttribute("href") || "/ram/embed";
+			});
+		}
+
+		root.addEventListener("click", (e) => {
+			if (!(e.target instanceof Element)) return;
+			if (e.target.closest(".ram-embed__icon")) return;
+			setSelected(null);
+		});
+	}
+</script>
+
+<style>
+	:global(html, body) {
+		height: 100%;
+	}
+
+	:global(body) {
+		margin: 0;
+		background: #008080;
+		font-family: "MS Sans Serif", Tahoma, Arial, sans-serif;
+		font-size: 12px;
+		color: #000;
+	}
+
+	.ram-embed {
+		height: 100vh;
+		display: grid;
+		grid-template-rows: auto 1fr auto;
+		background: silver;
+		box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
+	}
+
+	.ram-embed__header {
+		padding: 6px;
+		border-bottom: 1px solid grey;
+	}
+
+	.ram-embed__path {
+		display: grid;
+		grid-template-columns: auto 1fr;
+		gap: 8px;
+		align-items: center;
+	}
+
+	.ram-embed__path-label {
+		color: #222;
+	}
+
+	.ram-embed__path-value {
+		background: #fff;
+		padding: 3px 6px;
+		box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey;
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+	}
+
+	.ram-embed__content {
+		background: #fff;
+		margin: 0 6px;
+		box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey;
+		overflow: auto;
+	}
+
+	.ram-embed__grid {
+		padding: 10px;
+		display: grid;
+		grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
+		gap: 12px;
+		align-content: start;
+	}
+
+	.ram-embed__icon {
+		display: grid;
+		grid-template-rows: 44px auto auto;
+		gap: 4px;
+		align-items: start;
+		justify-items: center;
+		padding: 6px;
+		border: 1px solid transparent;
+		text-decoration: none;
+		color: inherit;
+		user-select: none;
+	}
+
+	.ram-embed__icon:focus-visible {
+		outline: 1px dotted #000;
+		outline-offset: 2px;
+	}
+
+	.ram-embed__icon[data-selected="true"] {
+		background: #000080;
+		color: #fff;
+		border-color: #000;
+	}
+
+	.ram-embed__icon-art {
+		width: 40px;
+		height: 40px;
+		background: #f5d76e;
+		box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
+		position: relative;
+	}
+
+	/* Simple "folder tab" */
+	.ram-embed__icon-art::before {
+		content: "";
+		position: absolute;
+		left: 4px;
+		top: -6px;
+		width: 18px;
+		height: 10px;
+		background: #f5d76e;
+		box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
+	}
+
+	.ram-embed__icon-title {
+		text-align: center;
+		line-height: 1.2;
+		max-width: 16ch;
+		word-break: break-word;
+	}
+
+	.ram-embed__icon-meta {
+		font-size: 11px;
+		opacity: 0.8;
+	}
+
+	.ram-embed__status {
+		display: flex;
+		justify-content: space-between;
+		gap: 10px;
+		padding: 6px;
+		border-top: 1px solid grey;
+		color: #222;
+	}
+
+	.ram-embed__status-hint {
+		white-space: nowrap;
+		opacity: 0.8;
+	}
+
+	@media (max-width: 480px) {
+		.ram-embed__grid {
+			grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
+		}
+		.ram-embed__status-hint {
+			display: none;
+		}
+	}
+</style>

+ 123 - 0
src/pages/ram/embed/[slug].astro

@@ -0,0 +1,123 @@
+---
+import { getCollection, render } from "astro:content";
+
+export async function getStaticPaths() {
+	const posts = await getCollection("blogs");
+	return posts
+		.filter((p) => (import.meta.env.PROD ? !p.data.draft : true))
+		.map((post) => ({ params: { slug: post.id } }));
+}
+
+const { slug } = Astro.params;
+const posts = await getCollection("blogs");
+const entry = posts.find((p) => p.id === slug);
+
+if (!entry) {
+	return new Response(null, { status: 404, statusText: "Not found" });
+}
+
+const { Content } = await render(entry);
+---
+
+<div class="ram-embed-post">
+	<header class="ram-embed-post__toolbar" aria-label="Toolbar">
+		<a class="ram-embed-post__back" href="/ram/embed">◄ Back to Posts</a>
+		<div class="ram-embed-post__title" title={entry.data.title}>{entry.data.title}</div>
+	</header>
+
+	<main class="ram-embed-post__content">
+		<article class="ram-embed-post__article">
+			<Content />
+		</article>
+	</main>
+</div>
+
+<style>
+	:global(html, body) {
+		height: 100%;
+	}
+
+	:global(body) {
+		margin: 0;
+		background: #008080;
+		font-family: "MS Sans Serif", Tahoma, Arial, sans-serif;
+		font-size: 12px;
+		color: #000;
+	}
+
+	.ram-embed-post {
+		height: 100vh;
+		display: grid;
+		grid-template-rows: auto 1fr;
+		background: silver;
+		box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
+	}
+
+	.ram-embed-post__toolbar {
+		display: grid;
+		grid-template-columns: auto 1fr;
+		gap: 10px;
+		align-items: center;
+		padding: 6px;
+		border-bottom: 1px solid grey;
+	}
+
+	.ram-embed-post__back {
+		display: inline-block;
+		padding: 3px 8px;
+		color: #000;
+		text-decoration: none;
+		background: silver;
+		box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
+	}
+
+	.ram-embed-post__back:active {
+		box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey;
+	}
+
+	.ram-embed-post__title {
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+		font-weight: bold;
+	}
+
+	.ram-embed-post__content {
+		background: #fff;
+		margin: 6px;
+		box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey;
+		overflow: auto;
+	}
+
+	.ram-embed-post__article {
+		padding: 14px;
+		max-width: 860px;
+		margin: 0 auto;
+	}
+
+	.ram-embed-post__article :global(p),
+	.ram-embed-post__article :global(li) {
+		font-size: 14px;
+		line-height: 1.5;
+	}
+
+	.ram-embed-post__article :global(pre) {
+		white-space: pre-wrap;
+		word-break: break-word;
+		font-size: 12px;
+	}
+
+	.ram-embed-post__article :global(a) {
+		color: #0000ee;
+	}
+
+	.ram-embed-post__article :global(a:visited) {
+		color: #551a8b;
+	}
+
+	.ram-embed-post__article :global(h1),
+	.ram-embed-post__article :global(h2),
+	.ram-embed-post__article :global(h3) {
+		margin-top: 1.1em;
+	}
+</style>

+ 46 - 0
src/pages/ram/index.astro

@@ -0,0 +1,46 @@
+---
+import { getCollection } from "astro:content";
+import DocumentLayout from "../../layouts/DocumentLayout.astro";
+
+// In static builds, query params are not available at build time.
+// Use a dedicated embed route instead of `?embed=1`.
+const embed = false;
+
+const allPosts = await getCollection("blogs");
+const posts = allPosts
+	.filter((p) => (import.meta.env.PROD ? !p.data.draft : true))
+	.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
+
+if (import.meta.env.DEV) console.log("[ram] blogs count", allPosts.length);
+
+---
+
+{embed ? (
+	<>
+		<ul class="tree-view">
+			{posts.map((post) => (
+				<li>
+					<a href={`/ram/${post.id}?embed=1`}>{post.data.title}</a>
+					<div>
+						<small>{post.data.pubDate.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "2-digit" })}</small>
+					</div>
+					{post.data.description && <p>{post.data.description}</p>}
+				</li>
+			))}
+		</ul>
+	</>
+) : (
+	<DocumentLayout title="RAM" subtitle="posts">
+		<ul class="tree-view">
+			{posts.map((post) => (
+				<li>
+					<a href={`/ram/${post.id}`}>{post.data.title}</a>
+					<div>
+						<small>{post.data.pubDate.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "2-digit" })}</small>
+					</div>
+					{post.data.description && <p>{post.data.description}</p>}
+				</li>
+			))}
+		</ul>
+	</DocumentLayout>
+)}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
src/styles/98.css


+ 8 - 0
src/styles/global.css

@@ -56,6 +56,14 @@ select {
 	left: 0px !important;
 }
 
+@font-face {
+	font-family: "Departure Mono";
+	src: url("/fonts/departure-mono/DepartureMono-Regular.woff2") format("woff2");
+	font-weight: 400;
+	font-style: normal;
+	font-display: swap;
+}
+
 @font-face {
 	font-family: "Pixelated MS Sans Serif";
 	src: url("/ms_sans_serif.woff") format("woff");

+ 56 - 0
src/styles/ram.css

@@ -0,0 +1,56 @@
+/* RAM pages need scrolling; the desktop homepage disables it globally in Layout.astro */
+.ram-page {
+	position: relative;
+	width: 100vw;
+	height: 100vh;
+	overflow: auto;
+	padding: 1rem;
+	box-sizing: border-box;
+}
+
+.ram-document {
+	position: relative;
+	max-width: 920px;
+	margin: 0 auto;
+}
+
+.ram-body {
+	background: white;
+	color: #222;
+	max-height: calc(100vh - 6rem);
+	overflow: auto;
+}
+
+.ram-body :where(h1,h2,h3,h4) {
+	color: #000;
+}
+
+.ram-body :where(p,li) {
+	line-height: 1.5;
+	font-size: 14px;
+}
+
+.ram-body :where(pre) {
+	white-space: pre-wrap;
+	word-break: break-word;
+}
+
+.ram-subtitle {
+	margin: 0 0 0.75rem 0;
+	color: #333;
+}
+
+.ram-close {
+	display: inline-block;
+	min-width: 16px;
+	min-height: 14px;
+	background: silver;
+	box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
+	background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='8' height='7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0h2v1h1v1h2V1h1V0h2v1H7v1H6v1H5v1h1v1h1v1h1v1H6V6H5V5H3v1H2v1H0V6h1V5h1V4h1V3H2V2H1V1H0V0z' fill='%23000'/%3E%3C/svg%3E");
+	background-repeat: no-repeat;
+	background-position: top 3px left 4px;
+}
+
+.ram-close:active {
+	box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey;
+}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است