background

All Journals

Currently at page #1 in 2 pages

10 entries in this page

今天和 @poscat0x04 面基了,开心!

Profiles

昨天和今天把博客的导航栏重写了一遍,实现了无 JS 的分级菜单。总体上效果不错,看不出来和 JS 实现的有什么区别。现在还没有增加过渡动画,但是我感觉比 JS 版本好实现。

我的导航栏分为桌面端和移动端两个形态,给两个平台单独实现比同一个结构应用不同的样式要更加方便。其中的重点就是如何实现菜单的展开和折叠。我比较推荐的方法有两种。第一种是 <details> 和 <summary> 的方法。在 <details> 中,标题放在 <summary> 中,剩下的可以展开和折叠的部分放在 <summary> 后面。

这种方法的好处是 <details> 自带点击切换显示功能,<details> 中的除了 <summary> 以外的元素都在 ::details-content 伪元素中。默认情况下 <summary> 会带有一个小箭头,不过这是因为 <summary> 默认的 inline 模式带有 ::marker 伪元素,只要改为 block 或其他就消失了。另外一个缺点就是 ::details-content 的切换时瞬时的,不好做过渡,且 ::details-content 属于 baseline 2025 特性,慎用。

这里在 <details> 上加 group,内部用 group-open: 来给打开的状态提供不同的样式。scale-y-100 和 -scale-y-100 配合过渡实现了箭头翻转动画。::details-content 部分用 absolute left-1/2 z-20 -translate-x-1/2 使菜单项部分独立悬浮出来,并与菜单按钮保持水平居中。以下代码实际上是在一个循环中的,因此多个 <details> 都会带有同一个 name="submenus",就可以实现最多只有一个菜单展开。

<details class="group relative" name="submenus">
  <summary
    class="flex gap-1 rounded-sm p-control text-fl-body leading-none font-bold text-foreground transition-colors duration-200 hover:bg-background"
  >
    <span class="scale-y-100 transition-transform duration-200 group-open:-scale-y-100">
      {{- partialCached "components/content/icon.html" (dict "name" "chevrons-down") "chevrons-down" -}}
    </span>
    <span class="select-none">{{ $name }}</span>
  </summary>

  <ul
    style="--color-card-active: color-mix(in srgb, var(--card) 80%, light-dark(white, black))"
    class="absolute left-1/2 z-20 -translate-x-1/2 rounded-sm bg-(--color-card-active) p-control"
  >
    {{- range $subEntry := $children -}}
      <li>
        {{- $subName := or (and $subEntry.Identifier (T $subEntry.Identifier)) $subEntry.Name -}}
        <a
          href="{{ $subEntry.URL }}"
          class="block rounded-sm p-control text-fl-body leading-none font-bold text-foreground transition-colors duration-200 hover:bg-background"
          >{{ $subName }}</a
        >
      </li>
    {{- end -}}
  </ul>
</details>

第二种方法是使用 <input type="checkbox"> 和 peer-checked: 来控制显示。Checkbox 可以存储状态,而我们可以隐藏这个 checkbox 本身,用另外一个关联的 <label> 作为按钮。菜单项部分是单独的元素,可以比较随意地放置。这是这种方法比 <details> 更灵活的地方。

这种方法还可以实现展开和折叠过渡动画。即使菜单项元素不好实现动画,也可以把它包装进恰好一个格子的 grid 单元格中,间接控制这个单元格的高度并应用过渡,就可以实现泛用的动画了。grid-template-rows 为 0fr 时就是不显示,1fr 就是恢复原高度。注意其中的 grid 单元格元素需要设置 overflow-hidden,因为单元格高度变化肯定要挤压内部菜单项的,需要隐藏溢出的部分。

使用一个 checkbox 控制一个菜单的展开,多个菜单就要多个 checkbox,这些 checkbox 之间是相互独立的,所以无法控制最多一个菜单展开。

注意 peer 状态的声明要在使用之前,也就是 checkbox 需要在菜单元素的前面,这取决于文档解析的顺序,只能引用之前声明过的 peer。

<div class="group">
  {{- $checkboxId := printf "mobile-menu-%s" (strings.ReplaceRE ` \"\n\t` "-" $name) -}}
  <input type="checkbox" id="{{ $checkboxId }}" name="{{ $checkboxId }}" class="peer hidden" />

  <label
    for="{{ $checkboxId }}"
    class="flex gap-1 rounded-sm p-control text-fl-body leading-none font-bold text-foreground transition-colors duration-200 hover:bg-background"
  >
    <span class="scale-y-100 transition-transform duration-200 group-has-checked:-scale-y-100">
      {{- partialCached "components/content/icon.html" (dict "name" "chevrons-down") "chevrons-down" -}}
    </span>
    <span class="select-none">{{ $name }}</span>
  </label>

  <div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-200 peer-checked:grid-rows-[1fr]">
    <ul class="overflow-hidden pl-component">
      {{- range $subEntry := $children -}}
        <li>
          {{- $subName := or (and $subEntry.Identifier (T $subEntry.Identifier)) $subEntry.Name -}}
          <a
            href="{{ $subEntry.URL }}"
            class="flex gap-1 rounded-sm p-control text-fl-body leading-none font-bold text-foreground transition-colors duration-200 hover:bg-background"
          >
            <span>{{- partialCached "components/content/icon.html" (dict "name" "link") "link" -}}</span>
            <span>{{ $subName }}</span>
          </a>
        </li>
      {{- end -}}
    </ul>
  </div>
</div>

最近又想重构 NixOS 配置了,传统 enable option 模式已经不再符合我的品味了。

我觉得 nixpkgs/nixos 里的模块可以算作底层的 primitive,同时也是副作用,就像 Haskell 中的 IO 一样。在朴素的 NixOS 配置架构中就是直接设置各种值,相当于 Haskell 中直接使用 IO。更相似的一点是,NixOS 配置和 IO 都不是自己就能产生副作用的,它们是对什么副作用的描述,真正产生副作用的分别是 nixos-rebuild 和 Haskell 的 runtime。Anyway,我这里只是想表达 NixOS 配置的 primitive 比较底层,我的理想配置架构不应该直接在这个层面上表达。

正如 Haskell 中经常用各种 Monad 来表达某种产生副作用的能力,进而构建 IO 之上的抽象,我觉得配置也可以这么做。首先高层次配置是按照 feature 划分的,每个 feature 高内聚,feature 之间则是松耦合,意味着和一个 feature 的含义的相关配置在一个地方实现且同时被启用,不同的 feature 的配置之间的联系尽可能少,相对独立,不容易出现开启一个而关闭另外一个会导致 eval 错误。

那么和 Monad 或者说一个 context 有什么关系呢?假设给 feature context 一个类型 Feature a,那么它在被运行(run)时可以产生底层的副作用(使用 NixOS 配置 primitive 进行设置),同时可以获得某种结果 a。这个结果可以作为其他 feature 的输入,即 feature 之间的数据依赖关系。run 一个 feature 并拿到结果,然后提供给其他 feature,其实就是 >>=。所以我们可以把所有的 feature 都定义为 monadic function,b -> Feature a,表示接受其他 feature 产生的计算结果,然后推导出自己的结果和相应的副作用。

举个实际例子,比如有 3 个 features:helix、environment、shell,helix 需要设置 EDITOR=hx、alias vim=hx、在 .config/helix 创建配置文件。environment 接收其他 features 所有的环境变量配置、在 .config/environment.d 创建环境变量配置文件。shell 需要接受所有 features 的 alias 配置,并且需要在 profile 脚本中使用 30-systemd-environment-d-generator 从 environment.d 生成环境变量配置脚本并执行以导出环境变量。

helix 的副作用是在 .config/helix 创建配置文件,其 run 结果是 environment 配置 EDITOR=hx 和 alias vim=hx,helix 不接收输入,所以可以表达为 Feature (EnvConfig, AliasConfig)。environment 就可以接收输入,也就是来自任意其他 features 的环境变量配置,所以可以表达为 EnvConfig -> Feature EnvFilePath。shell 则表达为 (AliasConfig, EnvFilePath) -> Feature (),因为在这个例子里 shell 不需要给其他 feature 提供什么计算结果,所以就返回 (),但是它仍然会产生写入 rc 和 profile 的副作用。

获得实际的底层配置的方法就是按照函数参数表达的 DAG 依赖关系,按拓扑序依次 run,所有的 features 都 run 后就会得到每个 feature 的副作用描述。其实在 NixOS 配置层面来看,这些副作用描述都是自己这一层的 API,换个说法就是获得了原始 NixOS module。

我们有了每个 feature 各自产生的原始 NixOS 配置后,其实是一个列表。其实 NixOS 模块是 Monoid,所谓的 <> 或者 mappend 就是 lib.mkMerge,我们就这样合并一下就可以获得最终的一份可以被 nixos-rebuild 应用的 NixOS 配置了。

It’s still OK to enjoy programming!

今天 Windows 和我的电脑的 UEFI 之一或全部再次作妖,把我的 NixOS 的启动项删了,还好 EFI 文件还在,可以手动档启动。

PS:使用 sudo bootctl install 拯救 NixOS,适用于 systemd-boot。

Dark Reader 的 UX 设计是有些奇怪的。扩展面板第一排是两个按钮,第一个是叉和勾图标及当前网站域名,第二个是 On 和 Off 的切换。

我个人认为这个叉和勾并不能准确反映含义,一种解释是是否对此网站应用暗色样式,无论其原配色是暗色还是亮色(这是实际的含义),另外一种解释是是否对此网站启用 Dark Reader(即启用时自动检测主题再考虑是否应用,关闭时不做任何操作)。

误导性在哪里呢?首先前面这个按钮和后面这个按钮在同一排,含义应当是相近的。后面这个控制了是否全局启用 Dark Reader,而前面这个的文字使用的是 Host,我觉得这更容易让人联想到这是对特定网站是否启用 Dark Reader(而真正代表这个含义的按钮在下面)。

我要如何才能区分出到底有没有用的是自带的暗色主题还是 Dark Reader 处理后的呢,要看小字 Dark theme detected。实在太容易被忽视了。

这些问题一度让我以为我的 blog 的 <meta name="color-scheme" content="dark light" /> 没有正确生效。

到底是谁想出来的用 2006-01-02 这个日期来格式化的(

好吧,某种程度上用特定的数字来格式化日期时间也有一定的道理。只要对于日期时间的每个部分都分配一个不同的数字,那么格式化时只需要用这个数字就可以指代年月日时分秒中的一个,而每一个部分的格式就可以以更加自然的方式表达,如一月就是 1、01、Jan、January 等等,不必为这些不同的格式选择比较抽象的符号。如果能够熟悉这个 magic date & time,那么其实也比较方便。

That said,yyyy mm dd 风格也不难就是了。复杂度仅仅是转移了,哪种方法更好见仁见智。

这几天重构博客的一些经验总结:

字号、行高、颜色等控制真正的内容的 utility class 直接加在对应元素上。这些 class 一般是可以继承的,但是他们反而不能依赖继承,应该写在叶子元素上。

Padding、gap、space 等 utility class 加在容器元素上,布局控制应该交给容器,而不是被控制元素自己控制自己。margin 系列 class 因为能够影响自身元素以外的布局,没有局部性质,对于组件或模板片段非常不友好。

元素的实际大小(即 border box)应该尽可能与视觉的大小一致,有利于布局的可预测性。如果是一个简单的文字,尽量不要有额外的 padding,如果作为 UI 文本用途甚至尽量不要有行高。如果有不同的背景色,大小就应该是这个色块的大小。

模板片段既可以作为组件,也可以是纯粹的内容,两种功能最好明确区分。作为组件的需要控制好自己的内部布局和展示,如一个菜单栏。作为内容的则不必设置样式,如字号、前景色等,因为这种内容是和其他内容拼接的,样式取决于上下文,这种例子有文字常量、图标、日期时间。

使用像 Go Template 这样模板引擎时,应该把不是纯内容的模板片段做成有隔离性和局部性的组件,即片段内就有且仅有一个顶层元素,防止片段使用的上下文的布局干扰片段内的布局。使用模板继承时,子模板可以扩展的块应该当作内容,模板定义处应该用单独一个元素包裹扩展点,防止块内布局影响外部。

Go Template 的一个坏处是允许 with 和 range 块隐式绑定表达式,在块内直接用 .Yyy 而不是 $xxx.Yyy 来引用。我选择显式绑定一个变量。

现在发现 flake 的 follows 如果填写的是空,指向的是自己,而不是的空 flake 🤯

在 REPL 中,我的 NixOS 配置中的 inputs.microvm.inputs.spectrum.output 和当前 flake 的输出是一样的(

PS:之前尝试写一个递归 pin flake inputs 的东西,结果出现 infinite recursion,应该就是这个问题了。

某种程度上好像也有道理,找一下规律,self.inputs.microvm.inputs.spectrum 应该是 "microvm/spectrum",self.inputs.microvm 应该是 "microvm",那么 self 就是 "" 了(

写了一个 Nix 函数库的 DSL,模仿 Haskell 的模块系统。

模块内:

{ lib, use }:
let
  inherit (use "data.nullable")
    mapNullables
    ;
in
# ...

模块树根部:

{
  pkgs ? import <nixpkgs> { },
  lib ? pkgs.lib,
}:
import ./modular.nix {
  inherit lib;
  moduleDir = ./src;
}

Haskell 的怎么组织模块,这里就怎么组织,不同之处就是用 use 代替 import。如果想要 qualified import,可以直接写 nullable = use "data.nullable"。

PS:其实这个语法更像 Zig(笑

modular.nix 的实现如下:

{
  lib,
  moduleDir,
}:
let
  evaluate = x: builtins.seq x x;

  singletonAttrs = name: value: { ${name} = value; };

  makeModuleTreeWithEnv =
    env: dir:
    let
      dirEntries = builtins.readDir dir;

      names = lib.lists.uniqueStrings (
        lib.flip lib.lists.concatMap (lib.attrsets.attrsToList dirEntries) (
          { name, value }:
          if value == "directory" then
            [ name ]
          else if value == "regular" && lib.strings.hasSuffix ".nix" name then
            [ (lib.strings.removeSuffix ".nix" name) ]
          else
            [ ]
        )
      );

      modules = lib.attrsets.listToAttrs (
        lib.flip lib.lists.map names (name: {
          inherit name;
          value =
            let
              children = lib.attrsets.optionalAttrs (lib.attrsets.hasAttr name dirEntries) (
                makeModuleTreeWithEnv env (dir + /${name}/.)
              );
            in
            if lib.attrsets.hasAttr "${name}.nix" dirEntries then
              rec {
                inherit children;
                path = dir + /${name}.nix;
                content = import path env;
              }
            else
              { inherit children; };
        })
      );
    in
    modules;

  makeModuleTree =
    dir:
    let
      moduleTree = makeModuleTreeWithEnv moduleEnv dir;

      moduleEnv = {
        inherit lib;

        use =
          modulePath:
          let
            rawAttrPath = lib.strings.splitString "." modulePath;
            attrPath = (lib.intersperse "children" rawAttrPath) ++ [ "content" ];
            err = builtins.abort "module path \"${modulePath}\" doesn't exist";
          in
          evaluate (lib.attrsets.attrByPath attrPath err moduleTree);
      };

      evalModuleTree =
        tree:
        builtins.deepSeq (lib.flip lib.lists.map (lib.attrsets.attrValues tree) (
          mod: builtins.seq (builtins.seq (mod.content or { }) (evalModuleTree mod.children)) 1
        )) tree;
    in
    {
      inherit (moduleEnv) use;
      moduleTree = evalModuleTree moduleTree;
    };
in
makeModuleTree moduleDir