damien's zone

home blog projects links rss

Introducing Mac Themes Garden!

2025 年 5 月 5 日 Mac Themes Garden

简短版本

我“发布”了 Mac Themes Garden! 这是一个展示来自 Classic Mac 时代的 3000 多个(且数量还在增加) Kaleidoscope 主题的网站,随时可以查看、下载和探索! 快去看看吧! 哦,还有一个 RSS feed,你可以订阅它来查看新增/更新的主题!

是的,还有一个按钮,你可以把它放在你的网站上! 在这里获取! 是不是很可爱?

Mac Themes Garden

现在,开始唠叨了。 注意:如果你是在 RSS 阅读器中阅读这篇文章,你可能想要直接在我的网站上打开这篇文章,因为它包含大量的 CSS 演示,这些在 RSS 阅读器中无法正常工作。

一些背景

如果你在网上认识我,你可能知道我已经在 Bluesky/Mastodon/Cohost 上运行了几年 Mac Themes Bot。

这个 Bot 的想法很简单,以每小时一次的频率展示来自 Mac OS X 和 Classic Mac 时代的自定义主题。因为我受到了 Twitter 上的 @kaleidoscopemac 的启发,所以我一开始只展示为 ShapeShifter by Unsanity 制作的主题。 不久之后,那个 Twitter 帐户被暂停了。 因为我已经有了 现成的工具 来抓取和发布 OS X 的主题,所以我想我可以将 Kaleidoscope (OS 7/8/9) 的主题也加入其中。 在尝试(但失败)从 Bot 的作者那里获取原始数据集后,我开始在 Wayback Machine 中抓取 Kaleidoscope Scheme Archive 的记录。 从那时起,我的 Bot 每小时都会发布主题,偶数小时发布经典主题,奇数小时发布 OS X 主题[1]

这些年过去了,Bot 一直在运行,让我和许多其他人在我运行它的各个网站上感到高兴。 Cohost 的化身尤其特别,因为 Cohost 是一个特别的网站,而且它充满了书呆子(褒义),人们会对其中的随机宝石感到疯狂。

大约在 2023 年初,我对这样一个事实感到沮丧:我拥有的 Kaleidoscope ~~主题~~ schemes 的图像只是那些来自 90 年代的 tiny .gif files

“记录” 25 年前的 schemes

所以我做了合理的事情:建立了一个非常手动的过程来截图和记录 大约 4,000 个可用主题的作者信息,目的是在完成后创建一个网站来展示所有内容。

无需赘述细节(你可以在 这里 阅读),这个过程是这样的:

这是它在 Airtable 中的样子:

6 rows inside an Airtable database, showing screenshots and scheme informationAirtable 数据库中的 6 行,显示了截图和 scheme 信息 在顺利的一天,我大概可以记录每小时十几个主题。

但是 Damien,你为什么不自动化这个过程?!

很高兴你问了! 我试过了! 但是,至少到目前为止,我找不到一种好的方法来可靠地执行我需要执行的操作。 这甚至还没有涉及到这样一个事实:有时一个 scheme 没有任何作者或年份信息! 有时 Kaleidoscope scheme 文件中的作者完全是错误的,而实际作者写在一个单独的“Read me”文件中。 有时几乎没有任何信息可以参考,有时 scheme 有 Bug,会导致我的 VM 崩溃。

在这一点上,我拥有的唯一自动化是:

除此之外,恐怕这都是手动的,因为我想记录的数据不是通过简单地查看文件就能直接提取的 😔。

无论如何, 我当时想“我将完成所有这些 schemes 的记录,然后 我将制作这个网站”……

亲爱的读者,我还没有完成。 根据我自己的估计,我大概完成了一半。 我不知道我什么时候会完全完成,但我的好朋友 Sage 敦促我:赶紧把该死的网站做好。

他们是对的,我应该更早地制作网站! 这是一个有趣且具有挑战性的消遣,而且 该死的 这些天我可以用消遣来放松一下。

制作网站

因此,大约在今年一月份,我开始了网站的工作。

第一步是与 Airtable API 接口,以便从我的数据库下载数据并将所有资产存储到我的存储库中的一个文件夹中。 没什么太疯狂的,但必须完成。 我确实需要小心缓存并且不要重新下载我已经存储在磁盘上的图像。 我们谈论的是大约 2,500 行,每行包含 3 个图像,你不想每次脚本需要运行时都重新下载 7,500 个 PNG 文件。

有了手头的数据,真正的乐趣就开始了。

Early version of the Mac Themes Garden site

这个概念验证使用了 Eleventy,我选择它的唯一原因是因为我刚刚使用它重新制作了我的网站(你现在正在阅读它!),所以我认为它会很适合,因为它简单且快速。

如果不是因为 WebC 如此不稳定,它可能就是了。 你看,我知道我想在网站的布局中使用多个类似 OS9 的“窗口”。 所以我想,当然,WebC 可以让我制作一个“组件”并以这种方式制作 UI,而且,我对天发誓,我无法以这种方式使其工作。 似乎 WebC 纯粹是为了 W eb C omponents,仅此而已,这本身很好,但这行不通。 我简要地试验了一个 paired short code,但我不会在 JavaScript 字符串中编写 HTML。 我想在这个项目中获得乐趣。

细节

所以我切换到了 Astro,它的 components 概念更接近我想要做的事情,而且我已经知道如何使用它,因为 erambert.me 使用它。

我不会对网站的制作过程进行逐个播放的描述,因为一旦球开始滚动,我的大部分时间都花在了玩 UI 创意和使用 Astro 的 content collections 上,这样将整个网站构建为一堆静态页面就不会花费太长时间。 不要误会我的意思,Astro 已经足够快了,没有 Eleventy 那么快,但仍然很快!

“问题”是我在处理很大的数字。 让我们考虑一下它们:

目前,如果我们假设每个 scheme 一个页面,每个作者一个页面(这将列出该作者的所有主题),我们将得到: 3,942 + 868 = 4,810 页。 这并不可怕,但已经有很多页面了,而且我们没有对它做任何花哨的事情。

让我们开始变得花哨,让我们添加分页,假设我们想每页显示 51 个主题。 其中:

const totalPages = (T * A) + Math.ceil(T / P)
// 4,888 pages

那是 4,888 页! 除非我想添加一些花哨的东西,比如作者页面 (A / 26 (每个字母一个)),这又是大约 33 页。 这使我们接近 5,000 页。 这很好,但这意味着每个页面最好都生成得非常快,以便构建时间不会失控。

这就是我必须小心的地方。 我希望作者页面显示给定作者制作的所有主题,我最初以这种方式天真地实现它:

export const getStaticPaths = (async () => {
 const authors = await getCollection("authors");
 return authors.map((a) => {
  return {
   props: { author: a },
   params: {
    author: a.data.slug,
   },
  };
 });
}) satisfies GetStaticPaths;
const { author } = Astro.props;
const themesByAuthor = (await getCollection("themes")).filter((t) => {
 return t.data.authors.some((a) => a.id === author.id);
});

当然,这看起来不错。 毕竟,它在开发中“只”需要大约 30 毫秒才能运行! 但是,根据我们之前的计算,这将为每个作者页面运行。 突然我们看到了:

30ms × 868 = 26,040 ms (26s) 😱!

毕竟,我们正在 868 次迭代 3,942 个 schemes,这不太好!

那么解决方案是什么?

当涉及到性能时,情况几乎总是如此:减少工作量,只做一次困难的工作! 我利用了 Astro 的 collection references。 我首先在我的 themes schema 中声明一个 authors 的引用:

const themes = defineCollection({
 loader: themesLoader,
 schema: z.object({
  name: z.string(),
+  authors: z.array(reference("authors")),
  year: z.string().optional(),
  mainThumbnail: z.string(),
  thumbnails: z.array(z.string()),
  archiveFile: z.string(),
  // ...
 }),
});

这意味着我可以简单地调用 getEntries 方法来获取给定主题的作者。

export const getStaticPaths = (async () => {
 const authors = await getCollection("authors");
 return authors.map((a) => {
  return {
   props: { author: a },
   params: {
    author: a.data.slug,
   },
  };
 });
}) satisfies GetStaticPaths;
const { author } = Astro.props;
const themesByAuthor = await getEntries(author.data.themes);

根据主题的数量,最多需要大约 10 毫秒,对于大多数页面,它需要不到 5 毫秒! 这好多了。

通过应用这种技术,我设法控制了站点的构建时间,并且 Astro 在不到 16 秒的时间内构建了几乎 5,000 个页面,并在彼此之间进行了各种查询!

"Astro's CLI showing it built 4920 pages in 15.54s"

变得可爱

如上所述,我知道我想为网站模拟 Mac OS 9 UI。 现在,我 可以 只是使用图像来制作 UI... 但是那样还有什么乐趣呢?

所以当然,我使用了本书中的每一个 CSS 技巧来实现这个外观。 让我介绍一下一些 UI 组件,并解释我是如何重新创建它们的。

窗口框架

alt text

这显然是 UI 的一个重要组成部分,所以我希望尽可能接近 OS 9 的实际外观。 让我们看一个简单的空例子:

Welcome!

许多样式都涉及使用多个 box shadows 来实现主 UI chrome 的不同区域中的“broken border”效果。 这是主窗口主体的样式(在上面的预览中为白色):

.macos9-window-body {
 border: 1px solid var(--primary-black);
 box-shadow:
  -1px -1px 0 rgb(from var(--primary-black) r g b / 40%),
  1px 1px 0 var(--primary-white);
 --top-left-shadow: var(--grays-600);
 --bottom-right-shadow: var(--primary-white);
 box-shadow:
  -1px -1px 0 var(--top-left-shadow),
  -1px 0px 0 var(--top-left-shadow),
  0 -1px 0 var(--top-left-shadow),
  1px 1px 0 var(--bottom-right-shadow),
  1px 0 0 var(--bottom-right-shadow),
  0 1px 0 var(--bottom-right-shadow);
 background-color: var(--primary-white);
}

标题栏

那部分很有趣,有很多事情要做,所以让我们一步一步地看。 这是它的外观和 HTML 标记:

Welcome!

<div class="macos9-window-titlebar">
 <button class="button close" data-action="close">
  <span class="button-dots"></span>
 </button>
 <span class="filler"></span>
 <span class="title-text">Welcome!</span>
 <span class="filler"></span>
 <button class="button zoom" data-action="zoom">
  <span class="button-dots"></span>
 </button>
 <button class="button collapse" data-action="collapse">
  <span class="button-dots"></span>
 </button>
</div>

“条纹”图案是使用重复的 CSS 渐变和每个侧面带有略微不同渐变的两个伪元素完成的:

.macos9-window-titlebar > span.filler {
 flex: 1;
 background-color: #dddddd;
 background-image: linear-gradient(#ffffff, #ffffff 50%, #777777 50%, #777777);
 background-repeat: repeat;
 background-size: 100% 2px;
 height: 12px;
 position: relative;
 &::before,
 &::after {
  content: "";
  position: absolute;
  width: 1px;
  background-size: 100% 2px;
  display: block;
 }
 &::before {
  left: 0;
  top: 0;
  bottom: 0;
  background-image: linear-gradient(#fff, #fff 50%, #cccccc 50%, #cccccc);
  border-bottom: 1px solid #cccccc;
 }
 &::after {
  right: 0;
  top: 0;
  bottom: 0;
  background-image: linear-gradient(#ccc, #ccc 50%, #777777 50%, #777777);
 }
}

然后我们有了窗口按钮,它们是……你猜对了,很多 box-shadows 和 borders 放在一起:

.macos9-window-titlebar > button {
 appearance: none;
 border: none;
 height: 13px;
 width: 13px;
 background: transparent;
 background-image: linear-gradient(135deg, #9a9a9a 0%, #f1f1f1 100%);
 background-size: 9px 9px;
 background-position: center;
 box-shadow:
  inset 1px 1px 0 var(--grays-700),
  inset -1px -1px 0 var(--primary-white),
  inset 0 0 0 2px var(--primary-black),
  inset 3px 3px 0 var(--primary-white),
  inset -3px -3px 0 var(--grays-700);
 position: relative;
 z-index: 0;
 &:active::before {
  content: "";
  inset: 2px;
  background-image: linear-gradient(
   135deg,
   rgba(53, 53, 53, 0.8) 0%,
   rgba(156, 156, 156, 0.8) 100%
  );
  display: block;
  position: absolute;
  z-index: 1;
 }
}
.macos9-window-titlebar > button .button-dots {
 position: absolute;
 inset: 0;
 display: block;
 &::before {
  content: "";
  position: absolute;
  top: 0;
  right: 0;
  width: 1px;
  height: 1px;
  background-color: #cccccc;
 }
 &::after {
  content: "";
  position: absolute;
  bottom: 0;
  left: 0;
  width: 1px;
  height: 1px;
  background-color: #cccccc;
 }
}

哦,还有一个巧妙的技巧:我正在使用 :has() 来有时重新对齐标题,以便在每一侧的按钮数量不均匀时,它实际上在视觉上居中:

.macos9-window-titlebar:has(.button.close, .button.zoom, .button.close)
.button.close + .filler {
 padding-left: calc(13px + var(--macos9-window-titlebar-gap));
}

按钮

这是设计的一部分,让我质疑我的理智和我对这个位元的承诺。 让我们看一下一个简单的按钮:

Press me!

看起来很简单,对吧? 好吧。 你如何在 CSS 中保留像素化的外观而不使用图像? 为什么,你当然会发疯,并使用 CSS Grid areas 自己绘制像素! 这是来自 Sage 的一个 suggestion

这需要一些计划,因为 CSS Grid areas 必须是矩形的。 那时我去了 Photoshop,并为每个区域绘制了一堆带有独特颜色的彩色矩形:

内部/外部阴影需要覆盖 38 个区域,以及纯背景需要 4 个区域! 使用 JSX 生成它很容易,我们注意添加一个 data-n 属性,这将帮助我们在 CSS 中定位这些区域:

<a className="os9-button"><div className="grid">{Array.from({ length: 38 }).map((_, index) => (
   <div
    key={index}
    className="shadow"
    data-n={String(index + 1).padStart(2, "0")}
   />
  ))}<div className="bgd" data-n="1"></div><div className="bgd" data-n="2"></div><div className="bgd" data-n="3"></div><div className="bgd" data-n="4"></div><div className="label">{children}</div></div></a>

然后,我们必须使用 Sass 来创建必要的选择器:

@use "sass:math";
@for $i from 1 through 38 {
 $n: $i;
 @if $n < 10 {
  $n: "0#{$n}";
 }
 .shadow[data-n="#{$n}"] {
  grid-area: s#{$n};
 }
}
@for $i from 1 through 4 {
 .bgd[data-n="#{$i}"] {
  grid-area: bg#{$i};
 }
}

然后……我们用代码“绘制”我们的 CSS areas:

.grid {
 display: grid;
 grid-template-areas:
  "... ... s01 s02 s02 s02 s03 ... ..."
  "... s04 s05 s06 s06 s06 s07 s08 ..."
  "s09 s10 s38 s11 s11 s11 s12 s13 s14"
  "s15 s16 s17 s17 bg1 bg1 s18 s19 s20"
  "s15 s16 s37 bg2 txt bg3 s18 s19 s20"
  "s15 s16 s37 bg2 bg4 s21 s18 s19 s20"
  "s22 s23 s24 s25 s26 s26 s27 s28 s29"
  "... s30 s31 s32 s32 s32 s32 s33 ..."
  "... ... s34 s35 s35 s35 s36 ... ...";
 grid-template-columns: repeat(4, max-content) 1fr repeat(5, max-content);
 grid-template-rows: repeat(4, max-content) 1fr repeat(4, max-content);
}

是的,这花了一段时间,并且尝试了多次才得到正确的 LMAO。 如果你很好奇,完整的 stylesheet is here

随机花絮

窗口控制

这些窗口实际上是交互式的! 你可以通过单击正确的按钮/双击标题栏来“zoom”(展开)主窗口并折叠它!

Welcome!

Play with the buttons!

const windowElement = document.querySelector('#demo-window');
windowElement.querySelectorAll("button").forEach((button) => {
 if (button.dataset.action === "collapse") {
  const titlebar = windowElement.querySelector(".macos9-window-titlebar");
  if (titlebar) {
   titlebar.addEventListener("dblclick", (e) => {
    if (e.target instanceof HTMLButtonElement) {
     return;
    }
    windowElement.classList.toggle("collapsed");
    window.getSelection()?.empty();
   });
  }
 }
 button.addEventListener("click", () => {
  const action = button.dataset.action;
  if (action === "collapse") {
   windowElement.classList.toggle("collapsed");
  } else if (action === "zoom") {
   windowElement.classList.toggle("zoomed");
  }
 });
});

Open Graph 图像

在为每个主题制作 open graph 图像时,我可以采取简单的路线并简单地删除主窗口的 PNG,如下所示:

Discord embed with a simple window without any background

这在 Discord 等应用程序中非常可爱,但由于错误的纵横比和对 alpha 通道的不支持,在其他任何地方看起来都很糟糕。

Bluesky embed with a simple window without any background

所以我采取了另一种方法:

但是,显然,手动进行这种合成将是一个糟糕的主意,而且我对 ImageMagick 不够熟练,所以我最终使用了 Vercel 的 satori 来布局这两个图像并为每个主题生成一个图像:

import type { InferEntrySchema } from "astro:content";
import satori from "satori";
import sharp from "sharp";
export async function generateOpenGraphImageForTheme(
 theme: InferEntrySchema<"themes">,
) {
 let blurredImageData: Buffer | undefined;
 const margin = 20;
 const imageDimension = {
  width: 1200,
  height: 630,
 };
 if (theme.thumbnails.length > 1) {
  blurredImageData = await sharp("public/" + theme.thumbnails[1])
   .resize(imageDimension.width, imageDimension.height, {
    fit: "cover",
    position: "top",
   })
   .blur(5)
   .toBuffer();
 }
 const mainThumbnailSharp = sharp("public" + theme.mainThumbnail);
 const mainThumbnail = await mainThumbnailSharp.png().toBuffer();
 const svg = await satori(
  <div
   style={{
    display: "flex",
    alignItems: "center",
    justifyItems: "center",
    width: "100%",
    height: "100%",
    position: "relative",
    backgroundColor: "white",
   }}
  >{blurredImageData && (
    <img
     src={toArrayBuffer(blurredImageData)}
     style={{
      position: "absolute",
      filter: "brightness(40%)",
      inset: 0,
     }}
    />
   )}<img
    src={toArrayBuffer(mainThumbnail)}
    style={{
     padding: margin,
     width: "100%",
     height: "100%",
     boxSizing: "border-box",
     objectFit: "contain",
     objectPosition: "center center",
    }}
   /></div>,
  {
   width: imageDimension.width,
   height: imageDimension.height,
   fonts: [],
  },
 );
 return sharp(Buffer.from(svg));
}
function toArrayBuffer(buffer: Buffer) {
 const arrayBuffer = new ArrayBuffer(buffer.length);
 const view = new Uint8Array(arrayBuffer);
 for (let i = 0; i < buffer.length; ++i) {
  view[i] = buffer[i];
 }
 return arrayBuffer;
}

然后我把一个 script 放在一起,通过生成多个进程来处理所有图像,整个脚本在我的 M1 Max Mac Studio 上需要大约 2 分 30 秒才能运行并生成大约 3900 个图像。

作为奖励,这些图像以后也将在 Mac Themes Bot 可用时使用。

接下来是什么

我不知道需要多长时间,但我希望继续/完成“记录”我可以访问的 schemes。 希望明年我能完成 lolsob。 你应该订阅 RSS feed 以查看我在更新它们时的情况!

除此之外,我还有很多想法:

就这样,伙计们,祝你生活愉快。 - damien

  1. 或者反之亦然,我真的不记得,也不想检查 Git 历史。 ↩︎

[ret