---
title: "Desktop-Style Homepage"
summary: "The homepage as a desk: draggable cards that tilt with speed and remember their position, plus icons that open resizable windows."
category: "layout (Pages & sections)"
origin: "original"
stack: ["html","css","javascript"]
tags: ["desktop metaphor","draggable","windows","handmade"]
demo: "https://zhuoqidev.com/design/demos/desk-home/"
url: "https://zhuoqidev.com/en/design/desk-home/"
license: "MIT"
added: "2026-10-14"
translation: "https://zhuoqidev.com/design/desk-home/index.md"
---

# Desktop-Style Homepage

> The homepage as a desk: draggable cards that tilt with speed and remember their position, plus icons that open resizable windows.

## What makes it work

- **Tilts while dragged**: while a card is held and dragged, the script computes a `--tilt` from horizontal speed, and CSS has `.pin.dragging { rotate: calc(var(--tilt) * 1deg); scale: 1.035 }`. On release `--tilt` is removed, and the `rotate` transition uses a spring curve written with `linear()`, so the card wobbles once and straightens.
- **Position remembered locally**: on release the position is saved to `localStorage`, horizontally as a "fraction of the desk width" and vertically in pixels, so cards stay on the desk when the window gets wider or narrower. "Tidy desk" clears it in one click.
- **Desktop icons**: a mouse click selects (the name gets a blue background) and only a double-click opens; keyboard Enter and a finger tap open directly, as on a real desktop.
- **Small windows**: drag the title bar to move, drag the bottom-right corner to resize; the red / yellow / green lights close / collapse / maximize, double-clicking the title bar also maximizes, and Esc closes. Collapse and maximize transition with the spring curve, while dragging follows the pointer with no transition.
- **Stars**: a faint ring of stars twinkles on empty desk space and is "pushed away" when the mouse approaches; clicking empty space spawns a small star.
- **Narrow screens**: below 720px all cards line up in one column, can't be dragged, and decorative elements are hidden.

## How to use it

1. Give the desk container `position: relative`, add `class="pin"` to each card, and for draggable ones also add `data-drag` and a unique `id` (used for saving the position).
2. Write each card's initial position in CSS, using something like `min(70%, calc(100% - card width))` for `left` so cards don't go off-screen on narrower displays.
3. Copy the "draggable cards" and "stars" sections of the script and change `KEY` (the storage key name).
4. For icons that open windows, write plain links: `<a class="desk-icon" href="/about/" data-win="...">`. In the example the window content comes from a `<template>`; on a real site you can use `<iframe src="link">` so the window shows an on-site page.
5. The narrow-screen breakpoint (720px in the example) should match your layout: below it there is no dragging and cards stack in one column in source order.

## Pitfalls

- If a card already has `transform: rotate(...)`, use the separate `rotate` property for the drag tilt, so the two stack; if both are in `transform` they override each other.
- Cards need `touch-action: none`, or the page scrolls along when a finger drags a card. But the single-column narrow layout must set it back to `auto`, otherwise a finger on a card can't scroll the page.
- Leave a 4px dead zone before a drag starts, otherwise just clicking a link inside a card counts as a drag and writes to storage once.
- `localStorage` throws in private mode or when site data is disabled, so wrap both reads and writes in `try/catch`.
- If a window contains an `iframe`, the `iframe` swallows pointer events while the window is dragged or resized; set `pointer-events: none` on it during the drag.

## Reference implementation (full source)

Live demo: https://zhuoqidev.com/design/demos/desk-home/. One file: save it as .html and open it.

### index.html

```html
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>桌面式首页</title>
    <style>
      :root {
        --paper: #fbf8f1;
        --paper-2: #f3eee2;
        --card: #fffdf8;
        --ink: #1c2230;
        --ink-2: #4a5163;
        --ink-3: #636877;
        --line: #e6dfd0;
        --dot: #d8d0bf;
        --blue: #2f5bd3;
        --blue-soft: #e8eefc;
        --blue-line: rgba(47, 91, 211, 0.35);
        --seal: #c0391f;
        --seal-soft: #fbe7e2;
        --hi: #ffe066;
        --note: #ffe88a;
        --sheet: #fffdf8; /* the photo paper stays light in both themes */
        --term: #161a22;
        --shadow: 0 1px 0 rgba(28, 34, 48, 0.04), 0 10px 28px -14px rgba(28, 34, 48, 0.28);
        --ease: cubic-bezier(0.16, 1, 0.3, 1);
        /* Springs as CSS easing curves: overshoot a little, then settle. */
        --spring: linear(
          0,
          0.062,
          0.215,
          0.411,
          0.615,
          0.799,
          0.95,
          1.059,
          1.126,
          1.158,
          1.161,
          1.145,
          1.116,
          1.083,
          1.051,
          1.023,
          1.001,
          0.986,
          0.977,
          0.974,
          0.974,
          0.978,
          0.983,
          0.988,
          0.994,
          0.998,
          1.001,
          1.003,
          1.004,
          1.004,
          1
        );
        --spring-soft: linear(
          0,
          0.042,
          0.145,
          0.282,
          0.432,
          0.578,
          0.711,
          0.825,
          0.916,
          0.985,
          1.033,
          1.063,
          1.079,
          1.084,
          1.08,
          1.072,
          1.06,
          1.048,
          1.035,
          1.024,
          1.015,
          1.007,
          1.001,
          0.997,
          0.995,
          0.993,
          0.993,
          0.993,
          0.994,
          0.995,
          1
        );
        --f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
        --f-serif: "Songti SC", STSong, "Noto Serif CJK SC", "Source Han Serif SC", SimSun, serif;
        --f-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
        --f-hand: "Kaiti SC", STKaiti, KaiTi, "AR PL UKai CN", cursive;
        color-scheme: light;
      }
      @media (prefers-color-scheme: dark) {
        :root {
          --paper: #14161b;
          --paper-2: #1c1f26;
          --card: #1e2129;
          --ink: #ece7dc;
          --ink-2: #bab6ac;
          --ink-3: #959aa8;
          --line: #31353f;
          --dot: #2c3039;
          --blue: #8eaaff;
          --blue-soft: #1f2a47;
          --blue-line: rgba(142, 170, 255, 0.45);
          --seal: #f0694e;
          --seal-soft: #3b211b;
          --hi: #7d6416;
          --note: #ecd77e;
          --term: #0d0f13;
          --shadow: 0 1px 0 rgba(0, 0, 0, 0.25), 0 10px 28px -14px rgba(0, 0, 0, 0.8);
          color-scheme: dark;
        }
      }
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
        color: var(--ink);
        font: 15px/1.7 var(--f-sans);
        overflow-x: hidden;
      }
      a {
        color: inherit;
      }

      /* ---------- the desk ---------- */
      .board {
        position: relative;
        height: max(640px, 100vh);
        overflow: hidden;
      }
      .pin {
        position: absolute;
        touch-action: none;
        user-select: none;
        transition:
          box-shadow 0.25s var(--ease),
          scale 0.55s var(--spring),
          rotate 0.7s var(--spring);
      }
      .pin[data-drag] {
        cursor: grab;
      }
      .pin a,
      .pin button {
        cursor: pointer;
      }
      /* Picked-up cards grow a little and lean into the drag (the script sets --tilt from the
         pointer's speed); let go and the spring easing swings them back flat. */
      .pin.dragging {
        cursor: grabbing;
        z-index: 30 !important;
        scale: 1.035;
        rotate: calc(var(--tilt, 0) * 1deg);
        box-shadow: 0 24px 48px -18px rgba(28, 34, 48, 0.45);
      }

      .card {
        background: var(--card);
        border: 2px dashed var(--blue-line);
        border-radius: 14px;
        box-shadow: var(--shadow);
      }
      .tape {
        --t: 255, 214, 61;
        position: absolute;
        top: -9px;
        left: 50%;
        width: 78px;
        height: 18px;
        transform: translateX(-50%) rotate(-3deg);
        background: repeating-linear-gradient(90deg, rgba(var(--t), 0.9) 0 5px, rgba(var(--t), 0.45) 5px 10px);
        border-radius: 1px;
      }
      .tape.blue {
        --t: 47, 91, 211;
        opacity: 0.55;
      }
      .kicker {
        display: block;
        margin-bottom: 8px;
        font: 500 11px/1 var(--f-mono);
        letter-spacing: 0.12em;
        color: var(--ink-3);
      }
      .mark {
        padding: 0 4px;
        background: linear-gradient(transparent 58%, var(--hi) 58%, var(--hi) 92%, transparent 92%);
      }

      /* Each card's spot on a wide desk. left uses min() so a card never hangs off a narrower desk. */
      #hero {
        left: 4%;
        top: 36px;
      }
      #photo {
        left: min(70%, calc(100% - 180px));
        top: 34px;
        width: 150px;
        transform: rotate(4deg);
        z-index: 5;
      }
      #doodle {
        left: min(37%, calc(100% - 420px));
        top: 150px;
        z-index: 3;
      }
      #sticky {
        left: min(46%, calc(100% - 470px));
        top: 210px;
        width: 220px;
        transform: rotate(3deg);
        z-index: 6;
      }
      #term {
        left: 4%;
        top: 214px;
        width: 330px;
        z-index: 5;
      }
      #memo {
        left: min(73%, calc(100% - 262px));
        top: 300px;
        width: 240px;
        transform: rotate(-2deg);
        z-index: 4;
      }
      #folder {
        left: 4%;
        top: 548px;
        width: 290px;
        z-index: 4;
      }
      #icons {
        left: min(40%, calc(100% - 560px));
        top: 452px;
        z-index: 3;
      }
      #cup {
        left: min(58%, calc(100% - 330px));
        top: 590px;
        z-index: 8;
      }
      #stamps {
        left: min(73%, calc(100% - 262px));
        top: 500px;
        width: 240px;
        z-index: 4;
      }
      #plant {
        left: min(88%, calc(100% - 70px));
        top: 210px;
        z-index: 8;
      }

      .hero h1 {
        margin: 0;
        font: 400 54px/1.1 var(--f-hand);
        white-space: nowrap;
      }
      .hero p {
        margin: 10px 0 0;
        font: 500 24px/1.2 var(--f-hand);
        color: var(--blue);
      }
      .hero .sub {
        margin-top: 10px;
        font: 400 12px/1.6 var(--f-mono);
        color: var(--ink-3);
        letter-spacing: 0.06em;
      }

      /* An instant photo: a CSS/SVG drawing stands in for the picture. */
      .polaroid {
        margin: 0;
        padding: 10px 10px 0;
        background: var(--sheet);
        border-radius: 3px;
        box-shadow:
          0 1px 0 rgba(28, 34, 48, 0.05),
          0 16px 30px -16px rgba(28, 34, 48, 0.45);
      }
      .polaroid svg {
        display: block;
        width: 100%;
        height: auto;
      }
      .polaroid figcaption {
        padding: 7px 2px 9px;
        text-align: center;
        font: 400 19px/1.2 var(--f-hand);
        color: #2b3242;
      }
      .polaroid .tape {
        top: -10px;
        width: 64px;
      }
      .pin.polaroid:hover {
        scale: 1.04;
      }

      .doodle {
        pointer-events: none;
      }
      .doodle text {
        font-family: var(--f-hand);
        font-size: 21px;
        fill: var(--seal);
      }
      .doodle path {
        fill: none;
        stroke: var(--seal);
        stroke-width: 2.2;
        stroke-linecap: round;
        stroke-linejoin: round;
      }

      .sticky {
        margin: 0;
        padding: 22px 20px 18px;
        background: var(--note);
        border-radius: 4px;
        box-shadow: 0 16px 24px -16px rgba(120, 90, 0, 0.45);
      }
      .sticky::before {
        content: "\201C";
        position: absolute;
        top: -18px;
        left: 8px;
        font:
          700 110px/1 Georgia,
          serif;
        color: #3b2f05;
        opacity: 0.13;
        pointer-events: none;
      }
      .sticky blockquote {
        margin: 0;
        font: 400 25px/1.35 var(--f-hand);
        color: #3b2f05;
      }
      .sticky figcaption {
        margin-top: 12px;
        font: 400 11px/1.5 var(--f-mono);
        color: #7a6510;
      }

      .terminal {
        background: var(--term);
        color: #d7dce6;
        border-radius: 14px;
        box-shadow: var(--shadow);
        overflow: hidden;
      }
      .terminal .bar {
        display: flex;
        gap: 7px;
        align-items: center;
        padding: 11px 14px;
        background: #1f2430;
      }
      .terminal .bar i {
        width: 11px;
        height: 11px;
        border-radius: 50%;
        background: #ff5f57;
      }
      .terminal .bar i:nth-child(2) {
        background: #febc2e;
      }
      .terminal .bar i:nth-child(3) {
        background: #28c840;
      }
      .terminal .bar span {
        margin-left: auto;
        font: 400 11px/1 var(--f-mono);
        color: #8b93a4;
      }
      .terminal pre {
        margin: 0;
        padding: 14px 18px 16px;
        font: 400 13px/1.7 var(--f-mono);
        white-space: pre-wrap;
      }
      .terminal .p {
        color: #5fd38a;
      }
      .terminal .o {
        color: #9aa3b5;
      }
      .terminal .y {
        background: #ffe066;
        color: #161a22;
        padding: 1px 6px;
        border-radius: 3px;
      }
      .cursor {
        display: inline-block;
        width: 8px;
        height: 15px;
        background: #5fd38a;
        vertical-align: -2px;
        animation: blink 1.1s steps(1) infinite;
      }
      @keyframes blink {
        50% {
          opacity: 0;
        }
      }

      .memo {
        padding: 18px 20px 16px;
      }
      .memo h3 {
        margin: 0 0 6px;
        font: 700 17px/1.45 var(--f-serif);
      }
      .memo p {
        margin: 0;
        font-size: 13px;
        line-height: 1.7;
        color: var(--ink-2);
      }
      .big-n {
        position: absolute;
        top: 2px;
        right: 14px;
        pointer-events: none;
        font: 700 60px/1 var(--f-hand);
        color: var(--ink);
        opacity: 0.07;
      }

      .folder .tab {
        position: absolute;
        top: -28px;
        left: 12px;
        height: 30px;
        padding: 6px 14px 0;
        background: var(--card);
        border: 2px dashed var(--blue-line);
        border-bottom: none;
        border-radius: 10px 10px 0 0;
        font: 500 12px/1 var(--f-mono);
        color: var(--ink-2);
      }
      .folder .in {
        padding: 16px 18px 10px;
      }
      .folder h3 {
        margin: 0 0 8px;
        font: 700 17px/1.3 var(--f-serif);
      }
      .folder ol {
        list-style: none;
        margin: 0;
        padding: 0;
      }
      .folder li {
        display: grid;
        grid-template-columns: 26px 1fr;
        gap: 6px;
        padding: 7px 0;
        border-top: 1px dashed var(--line);
        font-size: 13.5px;
        line-height: 1.5;
        color: var(--ink-2);
      }
      .folder li .n {
        font: 400 11px/1.9 var(--f-mono);
        color: var(--seal);
      }

      .stamps {
        display: flex;
        gap: 12px;
        align-items: center;
        padding: 16px 18px;
      }
      .stamp {
        display: grid;
        place-items: center;
        width: 60px;
        height: 60px;
        border-radius: 50%;
        border: 2.5px solid var(--seal);
        color: var(--seal);
        font: 800 13px/1 var(--f-mono);
        transform: rotate(-8deg);
        box-shadow:
          inset 0 0 0 4px var(--card),
          inset 0 0 0 5.5px var(--seal);
      }
      .stamp:nth-child(2) {
        transform: rotate(6deg);
      }
      .stamp:nth-child(3) {
        transform: rotate(-3deg);
      }
      .sticker {
        font-size: 48px;
        line-height: 1;
        filter: drop-shadow(0 6px 8px rgba(28, 34, 48, 0.18));
      }
      .sticker:hover {
        scale: 1.12;
        rotate: -4deg;
      }

      /* ---------- desk icons: click selects, double-click (or Enter / tap) opens a window ---------- */
      .desk-icons ul {
        display: flex;
        gap: 4px;
        margin: 0;
        padding: 0;
        list-style: none;
      }
      .desk-icon {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 7px;
        width: 86px;
        padding: 8px 2px 6px;
        border-radius: 10px;
        text-decoration: none;
        cursor: default !important;
      }
      .desk-icon .glyph {
        position: relative;
        display: grid;
        place-items: end center;
        height: 56px;
        filter: drop-shadow(0 5px 5px rgba(28, 34, 48, 0.16));
        transition:
          translate 0.5s var(--spring),
          scale 0.45s var(--spring);
      }
      .desk-icon:hover .glyph {
        translate: 0 -2px;
      }
      .desk-icon:active .glyph {
        scale: 0.88;
      }
      .desk-icon .back {
        fill: #6f95ea;
      }
      .desk-icon .front {
        fill: #a3bef6;
      }
      .desk-icon .page {
        fill: var(--sheet);
        stroke: #9aa0ad;
        stroke-width: 1.5;
      }
      .desk-icon .fold {
        fill: #ebe6da;
        stroke: #9aa0ad;
        stroke-width: 1.5;
        stroke-linejoin: round;
      }
      .desk-icon .lines {
        stroke: #d8d2c4;
        stroke-width: 2;
        stroke-linecap: round;
      }
      .desk-icon .badge {
        position: absolute;
        left: -8px;
        bottom: 7px;
        padding: 2px 5px;
        border-radius: 4px;
        font: 700 9px/1.2 var(--f-mono);
        text-transform: uppercase;
        color: #fff;
        background: #2f5bd3;
      }
      .desk-icon .badge.txt {
        background: #277a4b;
      }
      .desk-icon .name {
        padding: 2px 6px;
        border-radius: 5px;
        font: 500 12px/1.3 var(--f-mono);
        color: var(--ink);
      }
      .desk-icon.sel .glyph {
        filter: brightness(0.86) drop-shadow(0 5px 5px rgba(28, 34, 48, 0.16));
      }
      .desk-icon.sel .name {
        background: #2f5bd3;
        color: #fff;
      }
      .desk-icon:focus-visible {
        outline: 2px dashed var(--blue);
        outline-offset: 2px;
      }
      .icons-hint {
        margin: 2px 0 0 10px;
        font: 400 19px/1.2 var(--f-hand);
        color: var(--seal);
        rotate: -1.5deg;
      }

      /* ---------- tool strip: hint, clock, tidy-up ---------- */
      .tools {
        position: absolute;
        right: 16px;
        bottom: 14px;
        z-index: 35;
        display: flex;
        gap: 10px;
        align-items: center;
        padding: 3px 3px 3px 14px;
        border: 1px dashed var(--blue-line);
        border-radius: 999px;
        background: color-mix(in srgb, var(--card) 70%, transparent);
        -webkit-backdrop-filter: blur(10px);
        backdrop-filter: blur(10px);
        box-shadow: 0 8px 22px -14px rgba(28, 34, 48, 0.35);
      }
      .tools .hint {
        font: 400 11px/1 var(--f-mono);
        color: var(--ink-3);
      }
      .tools time {
        padding-left: 10px;
        border-left: 1px dashed var(--line);
        font: 600 12px/1 var(--f-mono);
        font-variant-numeric: tabular-nums;
      }
      .chip-btn {
        padding: 5px 11px;
        border: 1px dashed var(--ink-3);
        border-radius: 999px;
        background: transparent;
        color: var(--ink-2);
        font: 500 12px/1 var(--f-sans);
        cursor: pointer;
        transition: scale 0.45s var(--spring);
      }
      .chip-btn:hover {
        border-style: solid;
        color: var(--ink);
        background: var(--card);
      }
      .chip-btn:active {
        scale: 0.93;
      }

      /* ---------- stars on the bare desk ---------- */
      .desk-stars {
        position: absolute;
        inset: 0;
        z-index: 0;
        pointer-events: none;
      }
      .desk-stars span {
        position: absolute;
        line-height: 1;
        color: color-mix(in srgb, var(--blue) 75%, transparent);
        transition: translate 0.6s var(--ease);
        animation: star-twinkle 3.6s ease-in-out infinite;
      }
      @keyframes star-twinkle {
        0%,
        100% {
          opacity: 0.45;
          scale: 0.8;
          rotate: -8deg;
        }
        50% {
          opacity: 0.9;
          scale: 1.1;
          rotate: 8deg;
        }
      }
      .click-star {
        position: fixed;
        z-index: 70;
        pointer-events: none;
        translate: -50% -50%;
        color: #ffe066;
        -webkit-text-stroke: 1px #c99a00;
        line-height: 1;
        animation: star-pop 0.7s ease-out forwards;
      }
      @keyframes star-pop {
        0% {
          opacity: 0.95;
          transform: scale(0) rotate(0);
        }
        30% {
          opacity: 0.9;
          transform: scale(1.2) rotate(10deg);
        }
        60% {
          opacity: 0.75;
          transform: scale(1) rotate(-3deg);
        }
        100% {
          opacity: 0;
          transform: scale(0.8);
        }
      }
      /* Cards land on the desk one after another. `translate` composes with each card's rotation. */
      .board > .pin:not(#hero) {
        animation: pin-in 0.5s var(--ease) both;
      }
      .board > .pin:nth-child(3) {
        animation-delay: 0.05s;
      }
      .board > .pin:nth-child(4) {
        animation-delay: 0.1s;
      }
      .board > .pin:nth-child(5) {
        animation-delay: 0.15s;
      }
      .board > .pin:nth-child(6) {
        animation-delay: 0.2s;
      }
      .board > .pin:nth-child(7) {
        animation-delay: 0.25s;
      }
      .board > .pin:nth-child(n + 8) {
        animation-delay: 0.3s;
      }
      @keyframes pin-in {
        from {
          opacity: 0;
          translate: 0 16px;
        }
      }

      /* ---------- windows ---------- */
      .wins {
        position: fixed;
        inset: 0;
        z-index: 45;
        pointer-events: none;
      }
      .win {
        position: absolute;
        display: flex;
        flex-direction: column;
        min-width: 260px;
        pointer-events: auto;
        background: var(--paper);
        border: 1px solid var(--line);
        border-radius: 12px;
        overflow: hidden;
        box-shadow:
          0 34px 70px -28px rgba(28, 34, 48, 0.55),
          0 2px 6px rgba(28, 34, 48, 0.08);
        animation: win-in 0.5s var(--spring-soft);
      }
      .win.closing {
        animation: win-out 0.16s ease-in forwards;
      }
      @keyframes win-in {
        from {
          opacity: 0;
          scale: 0.9;
          translate: 0 14px;
        }
      }
      @keyframes win-out {
        to {
          opacity: 0;
          scale: 0.95;
        }
      }
      .win.morph {
        transition:
          left 0.5s var(--spring-soft),
          top 0.5s var(--spring-soft),
          width 0.5s var(--spring-soft),
          height 0.5s var(--spring-soft);
      }
      .win.max {
        left: 12px !important;
        top: 12px !important;
        width: calc(100vw - 24px) !important;
        height: calc(100vh - 24px) !important;
      }
      .win.shaded {
        height: auto !important;
      }
      .win.shaded .win-body,
      .win.shaded .win-grip {
        display: none;
      }
      .win-bar {
        display: flex;
        align-items: center;
        gap: 10px;
        height: 38px;
        padding: 0 14px;
        flex: none;
        background: var(--paper-2);
        border-bottom: 1px solid var(--line);
        cursor: grab;
        user-select: none;
        touch-action: none;
      }
      .win.busy .win-bar {
        cursor: grabbing;
      }
      .lights {
        display: flex;
        gap: 8px;
      }
      .lights button {
        display: grid;
        place-items: center;
        width: 12px;
        height: 12px;
        padding: 0;
        border: 0;
        border-radius: 50%;
        background: #ff5f57;
        cursor: pointer;
        font: 700 10px/1 var(--f-sans);
        color: transparent;
      }
      .lights .l-shade {
        background: #febc2e;
      }
      .lights .l-zoom {
        background: #28c840;
      }
      .lights button::after {
        content: "\00D7";
      }
      .lights .l-shade::after {
        content: "\2013";
      }
      .lights .l-zoom::after {
        content: "+";
      }
      .lights:hover button,
      .lights button:focus-visible {
        color: rgba(0, 0, 0, 0.55);
      }
      .lights button:focus-visible {
        outline: 2px solid var(--blue);
        outline-offset: 2px;
      }
      .win:not(.on) .lights button {
        background: var(--line);
      }
      .win-title {
        flex: 1;
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        text-align: center;
        margin-right: 52px;
        font: 600 13px/1 var(--f-sans);
        color: var(--ink-2);
      }
      .win:not(.on) .win-title {
        color: var(--ink-3);
      }
      .win-body {
        flex: 1;
        min-height: 0;
        overflow: auto;
        padding: 18px 22px 22px;
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
      }
      .win-body h2 {
        margin: 0 0 8px;
        font: 400 30px/1.2 var(--f-hand);
        color: var(--blue);
      }
      .win-body p,
      .win-body li {
        color: var(--ink-2);
        font-size: 14px;
      }
      .win-body ul {
        padding-left: 1.2em;
      }
      .win-body .crumb {
        font: 400 12px/1 var(--f-mono);
        color: var(--ink-3);
      }
      .win-body .crumb b {
        color: var(--seal);
        font-weight: 400;
      }
      .win-grip {
        position: absolute;
        right: 0;
        bottom: 0;
        width: 18px;
        height: 18px;
        cursor: nwse-resize;
        touch-action: none;
        background: linear-gradient(
          135deg,
          transparent 52%,
          var(--ink-3) 52% 58%,
          transparent 58% 70%,
          var(--ink-3) 70% 76%,
          transparent 76%
        );
        opacity: 0.55;
      }

      /* ---------- narrow screens: the same cards as one column, nothing to drag ---------- */
      @media (max-width: 720px) {
        .board {
          height: auto;
          display: flex;
          flex-direction: column;
          align-items: flex-start;
          gap: 28px;
          padding: 28px 16px 40px;
        }
        .pin {
          position: relative;
          left: auto !important;
          top: auto !important;
          width: auto;
          max-width: 100%;
          touch-action: auto;
          animation: none !important;
        }
        .pin[data-drag] {
          cursor: auto;
        }
        .sticky,
        .terminal,
        .memo,
        .folder,
        .stamps {
          align-self: stretch;
        }
        #photo {
          order: -1;
          align-self: center;
          width: 150px;
        }
        #hero {
          order: -2;
        }
        .hero h1 {
          font-size: 38px;
          white-space: normal;
        }
        .hero p {
          font-size: 20px;
        }
        #folder {
          margin-top: 18px;
        }
        .doodle,
        .sticker,
        .tools,
        .icons-hint {
          display: none;
        }
        .desk-icons ul {
          flex-wrap: wrap;
        }
        .desk-icon {
          width: 76px;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        .desk-stars span {
          opacity: 0.6;
        }
        *,
        *::before,
        *::after {
          animation: none !important;
          transition: none !important;
        }
      }
    </style>
  </head>
  <body>
    <main class="board" id="board">
      <section class="pin hero" id="hero">
        <h1>小王的<span class="mark">工作台</span></h1>
        <p>做点小东西，记点小笔记</p>
        <div class="sub">小王 · 独立开发 · 喜欢纸和笔</div>
      </section>

      <figure class="pin polaroid" id="photo" data-drag>
        <span class="tape" aria-hidden="true"></span>
        <svg viewBox="0 0 130 130" role="img" aria-label="一张手画的风景">
          <rect width="130" height="130" fill="#cfe3f5" />
          <circle cx="96" cy="34" r="14" fill="#ffd25e" />
          <path d="M18 34c4-6 13-6 16 0 5-2 10 2 9 6H14c-2-3 0-6 4-6z" fill="#fff" />
          <path d="M0 92 30 58l22 22 20-26 30 32 28-18v62H0z" fill="#8fb98b" />
          <path d="M0 106c30-12 62-10 130-4v28H0z" fill="#6c9a6a" />
          <path d="M60 130c4-12 4-20 10-30" stroke="#e9dcb8" stroke-width="5" fill="none" stroke-linecap="round" />
        </svg>
        <figcaption>去年夏天 ~</figcaption>
      </figure>

      <svg class="pin doodle" id="doodle" width="150" height="80" viewBox="0 0 150 80" aria-hidden="true">
        <text x="2" y="20">拖我试试</text>
        <path d="M22 32 C 52 42, 92 44, 126 68" />
        <path d="M112 72 L127 69 L121 55" />
      </svg>

      <figure class="pin sticky" id="sticky" data-drag>
        <blockquote>先做出来，再做好看。</blockquote>
        <figcaption>— 贴在显示器边上的便签</figcaption>
      </figure>

      <div class="pin terminal" id="term" data-drag>
        <div class="bar"><i></i><i></i><i></i><span>xiaowang@desk — zsh</span></div>
        <pre><span class="p">$</span> whoami
<span class="o">小王 · 写网页，也做小玩具</span>

<span class="p">$</span> ls projects/ | wc -l
<span class="o">6  # 有两个还没做完</span>

<span class="p">$</span> cat todo.md
<span class="o">给按钮加回弹 · 整理照片墙</span>

<span class="p">$</span> open <span class="y">notes/</span> <span class="cursor"></span></pre>
      </div>

      <article class="pin card memo" id="memo" data-drag>
        <span class="tape blue"></span>
        <span class="big-n" aria-hidden="true">01</span>
        <span class="kicker">笔记 · 10-12</span>
        <h3>给按钮加一点回弹</h3>
        <p>用 CSS 的 linear() 写一条弹簧曲线，按下缩一点、松开弹回来，不用动画库。</p>
      </article>

      <section class="pin card folder" id="folder" data-drag>
        <span class="tab">projects/</span>
        <div class="in">
          <h3>最近在做</h3>
          <ol>
            <li><span class="n">01</span>一个记账小程序</li>
            <li><span class="n">02</span>照片墙生成器</li>
            <li><span class="n">03</span>番茄钟浏览器扩展</li>
          </ol>
        </div>
      </section>

      <section class="pin card stamps" id="stamps" data-drag aria-label="常用的三样">
        <span class="stamp">HTML</span>
        <span class="stamp">CSS</span>
        <span class="stamp">JS</span>
      </section>

      <div class="pin desk-icons" id="icons">
        <ul aria-label="桌面图标">
          <li>
            <a class="desk-icon" href="#projects" data-win="w-projects">
              <span class="glyph" aria-hidden="true">
                <svg width="58" height="46" viewBox="0 0 58 46">
                  <path class="back" d="M1 6a3 3 0 0 1 3-3h15l5 5h30a3 3 0 0 1 3 3v29a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3z" />
                  <path class="front" d="M1 14h56v26a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3z" />
                </svg>
              </span>
              <span class="name">作品/</span>
            </a>
          </li>
          <li>
            <a class="desk-icon" href="#notes" data-win="w-notes">
              <span class="glyph" aria-hidden="true">
                <svg width="58" height="46" viewBox="0 0 58 46">
                  <path class="back" d="M1 6a3 3 0 0 1 3-3h15l5 5h30a3 3 0 0 1 3 3v29a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3z" />
                  <path class="front" d="M1 14h56v26a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3z" />
                </svg>
              </span>
              <span class="name">笔记/</span>
            </a>
          </li>
          <li>
            <a class="desk-icon" href="#about" data-win="w-about">
              <span class="glyph" aria-hidden="true">
                <svg width="44" height="54" viewBox="0 0 44 54">
                  <path class="page" d="M4 1h26l13 13v36a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V4a3 3 0 0 1 3-3z" />
                  <path class="fold" d="M30 1v10a3 3 0 0 0 3 3h10" />
                  <path class="lines" d="M9 22h20M9 28h25M9 34h16" />
                </svg>
                <span class="badge">md</span>
              </span>
              <span class="name">关于.md</span>
            </a>
          </li>
          <li>
            <a class="desk-icon" href="#hello" data-win="w-hello">
              <span class="glyph" aria-hidden="true">
                <svg width="44" height="54" viewBox="0 0 44 54">
                  <path class="page" d="M4 1h26l13 13v36a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V4a3 3 0 0 1 3-3z" />
                  <path class="fold" d="M30 1v10a3 3 0 0 0 3 3h10" />
                  <path class="lines" d="M9 22h20M9 28h25M9 34h16" />
                </svg>
                <span class="badge txt">txt</span>
              </span>
              <span class="name">留言.txt</span>
            </a>
          </li>
        </ul>
        <p class="icons-hint" aria-hidden="true">双击图标打开 ↑</p>
      </div>

      <div class="pin sticker" id="cup" data-drag aria-hidden="true">☕</div>
      <div class="pin sticker" id="plant" data-drag aria-hidden="true">🪴</div>

      <div class="tools">
        <span class="hint">卡片能拖，位置记在本机</span>
        <time data-clock hidden></time>
        <button class="chip-btn" id="reset" type="button">整理桌面</button>
      </div>
    </main>

    <!-- What each desk icon opens. In a real site this would be the page itself in an iframe. -->
    <template id="w-projects">
      <div class="crumb">~/projects/ <b>ls</b></div>
      <h2>作品</h2>
      <ul>
        <li><b>记账小程序</b>：拍小票自动记一笔，月底出一张饼图。</li>
        <li><b>照片墙生成器</b>：把一组照片排成拍立得墙，可以导出长图。</li>
        <li><b>番茄钟扩展</b>：25 分钟一响，响的时候把网页调成灰色。</li>
      </ul>
    </template>
    <template id="w-notes">
      <div class="crumb">~/notes/ <b>ls -lt</b></div>
      <h2>笔记</h2>
      <ul>
        <li>10-12 · 给按钮加一点回弹</li>
        <li>10-05 · 拖拽时让卡片跟着速度倾斜</li>
        <li>09-28 · 用 localStorage 记住布局，别忘了 try/catch</li>
      </ul>
    </template>
    <template id="w-about">
      <div class="crumb">~/ <b>cat 关于.md</b></div>
      <h2>你好，我是小王</h2>
      <p>白天写网页，晚上做些小玩具。这张桌子上的卡片都能拖，换个地方放，下次打开还在原处。</p>
      <p>窗口可以拖标题栏移动、拖右下角改大小，红黄绿三个灯分别是关闭、收起、放大，按 Esc 也能关。</p>
    </template>
    <template id="w-hello">
      <div class="crumb">~/ <b>cat 留言.txt</b></div>
      <h2>来打个招呼</h2>
      <p>有想法、发现错别字，或者只是路过，都可以写信到 hi@example.com。</p>
    </template>

    <script>
      // localStorage can be missing or throw (private mode, blocked site data), so every access is guarded.
      const store = {
        get(key) {
          try {
            return JSON.parse(localStorage.getItem(key) ?? "null");
          } catch {
            return null;
          }
        },
        set(key, value) {
          try {
            localStorage.setItem(key, JSON.stringify(value));
          } catch {}
        },
        del(key) {
          try {
            localStorage.removeItem(key);
          } catch {}
        },
      };

      const KEY = "desk-home-spots";
      const board = document.getElementById("board");
      const wide = matchMedia("(min-width: 721px)");
      const still = matchMedia("(prefers-reduced-motion: reduce)");
      const pins = [...board.querySelectorAll(".pin[data-drag]")];
      let z = 10;

      // ---------- draggable cards ----------
      // A spot is stored as a share of the desk's width plus a top in px, so it still fits when the
      // window is resized.
      const place = () => {
        const saved = store.get(KEY) ?? {};
        for (const p of pins) {
          const s = saved[p.id];
          p.style.left = wide.matches && s ? `${(s.x * 100).toFixed(3)}%` : "";
          p.style.top = wide.matches && s ? `${s.y}px` : "";
        }
      };
      place();
      wide.addEventListener("change", place);

      for (const p of pins) {
        p.addEventListener("pointerdown", (e) => {
          if (!wide.matches || e.button !== 0 || e.target.closest("a, button")) return;
          const startX = e.clientX;
          const startY = e.clientY;
          const ox = p.offsetLeft;
          const oy = p.offsetTop;
          let moved = false;
          // Lean into the drag: the faster the card moves sideways, the more it tilts.
          let lastX = e.clientX;
          let lastT = e.timeStamp;
          let tilt = 0;
          p.setPointerCapture(e.pointerId);
          p.style.zIndex = String(++z);
          const move = (ev) => {
            const dx = ev.clientX - startX;
            const dy = ev.clientY - startY;
            if (!moved && Math.hypot(dx, dy) < 4) return;
            moved = true;
            p.classList.add("dragging");
            if (!still.matches) {
              const dt = Math.max(8, ev.timeStamp - lastT);
              tilt = tilt * 0.7 + Math.max(-9, Math.min(9, ((ev.clientX - lastX) / dt) * 6)) * 0.3;
              p.style.setProperty("--tilt", tilt.toFixed(2));
            }
            lastX = ev.clientX;
            lastT = ev.timeStamp;
            p.style.left = `${Math.max(0, Math.min(board.clientWidth - p.offsetWidth, ox + dx))}px`;
            p.style.top = `${Math.max(0, Math.min(board.clientHeight - 40, oy + dy))}px`;
          };
          const up = () => {
            p.removeEventListener("pointermove", move);
            p.classList.remove("dragging");
            p.style.removeProperty("--tilt");
            if (!moved) return;
            const all = store.get(KEY) ?? {};
            all[p.id] = { x: p.offsetLeft / board.clientWidth, y: p.offsetTop };
            store.set(KEY, all);
            p.style.left = `${(all[p.id].x * 100).toFixed(3)}%`;
          };
          p.addEventListener("pointermove", move);
          p.addEventListener("pointerup", up, { once: true });
          p.addEventListener("pointercancel", up, { once: true });
        });
      }

      document.getElementById("reset").addEventListener("click", () => {
        store.del(KEY);
        place();
      });

      // ---------- stars ----------
      // A click on the bare desk leaves a little star behind.
      board.addEventListener("click", (e) => {
        if (still.matches || e.target.closest(".pin, .tools")) return;
        const star = document.createElement("span");
        star.className = "click-star";
        star.textContent = "✦";
        star.setAttribute("aria-hidden", "true");
        star.style.left = `${e.clientX}px`;
        star.style.top = `${e.clientY}px`;
        star.style.fontSize = `${14 + Math.random() * 12}px`;
        document.body.append(star);
        star.addEventListener("animationend", () => star.remove(), { once: true });
      });

      // Faint stars twinkle on the bare desk and drift out of the pointer's way (with reduced motion
      // they just sit there). Fixed spots (percent of the desk) so the sky looks the same on every visit.
      {
        const sky = document.createElement("div");
        sky.className = "desk-stars";
        sky.setAttribute("aria-hidden", "true");
        const spots = [
          [33, 4],
          [58, 3],
          [93, 10],
          [36, 16],
          [64, 14],
          [96, 32],
          [34, 42],
          [62, 46],
          [97, 50],
          [36, 66],
          [68, 70],
          [6, 92],
          [28, 88],
          [55, 90],
          [82, 94],
          [92, 72],
        ];
        for (const [i, [x, y]] of spots.entries()) {
          const s = document.createElement("span");
          s.textContent = "✦";
          s.style.left = `${x}%`;
          s.style.top = `${y}%`;
          s.style.fontSize = `${11 + ((i * 7) % 10)}px`;
          s.style.animationDelay = `${-((i * 1.37) % 4).toFixed(2)}s`;
          s.style.animationDuration = `${3 + (i % 4) * 0.7}s`;
          sky.append(s);
        }
        board.prepend(sky);
      }
      if (!still.matches) {
        const sky = board.querySelector(".desk-stars");
        const stars = [...sky.children];
        let frame = 0;
        let px = -1e4;
        let py = -1e4;
        const push = () => {
          frame = 0;
          // Measure from where each star rests (offsets ignore its current drift).
          const box = sky.getBoundingClientRect();
          for (const s of stars) {
            const dx = box.left + s.offsetLeft + s.offsetWidth / 2 - px;
            const dy = box.top + s.offsetTop + s.offsetHeight / 2 - py;
            const d = Math.hypot(dx, dy);
            const f = d < 110 ? (110 - d) * 0.45 : 0;
            s.style.translate = f ? `${((dx / d) * f).toFixed(1)}px ${((dy / d) * f).toFixed(1)}px` : "";
          }
        };
        board.addEventListener("pointermove", (e) => {
          px = e.clientX;
          py = e.clientY;
          frame ||= requestAnimationFrame(push);
        });
        board.addEventListener("pointerleave", () => {
          px = py = -1e4;
          frame ||= requestAnimationFrame(push);
        });
      }

      // ---------- desk icons open little windows ----------
      const icons = [...document.querySelectorAll("a.desk-icon")];
      const layer = document.createElement("div");
      layer.className = "wins";
      document.body.append(layer);
      let wz = 1;
      let opened = 0;

      const front = (w) => {
        if (w.style.zIndex !== String(wz)) w.style.zIndex = String(++wz);
        for (const o of layer.querySelectorAll(".win.on")) o.classList.remove("on");
        w.classList.add("on");
      };

      // Follow the pointer from pointerdown until it lets go.
      function track(w, e, step) {
        const el = e.currentTarget;
        const x0 = e.clientX;
        const y0 = e.clientY;
        el.setPointerCapture(e.pointerId);
        w.classList.add("busy");
        const move = (ev) => step(ev.clientX - x0, ev.clientY - y0);
        const up = () => {
          el.removeEventListener("pointermove", move);
          w.classList.remove("busy");
        };
        el.addEventListener("pointermove", move);
        el.addEventListener("pointerup", up, { once: true });
        el.addEventListener("pointercancel", up, { once: true });
      }

      function open(icon) {
        const id = icon.dataset.win;
        const existing = layer.querySelector(`.win[data-id="${id}"]`);
        if (existing) {
          existing.classList.remove("shaded");
          front(existing);
          return;
        }
        const label = icon.querySelector(".name").textContent;
        const w = document.createElement("section");
        w.className = "win";
        w.dataset.id = id;
        w.setAttribute("role", "dialog");
        w.setAttribute("aria-label", label);
        w.innerHTML = `
          <header class="win-bar">
            <span class="lights">
              <button type="button" class="l-close" aria-label="关闭" title="关闭"></button>
              <button type="button" class="l-shade" aria-label="收起" title="收起"></button>
              <button type="button" class="l-zoom" aria-label="放大" title="放大"></button>
            </span>
            <span class="win-title"></span>
          </header>
          <div class="win-body" tabindex="-1"></div>
          <span class="win-grip" aria-hidden="true" title="拖动调整大小"></span>`;
        w.querySelector(".win-title").textContent = label;
        const body = w.querySelector(".win-body");
        body.append(document.getElementById(id).content.cloneNode(true));

        const vw = document.documentElement.clientWidth;
        const vh = window.innerHeight;
        const width = Math.min(440, vw - 24);
        const height = Math.min(320, vh - 60);
        const step = (opened++ % 5) * 28;
        w.style.width = `${width}px`;
        w.style.height = `${height}px`;
        w.style.left = `${Math.max(12, Math.min(vw - width - 12, (vw - width) / 2 + step))}px`;
        w.style.top = `${Math.max(12, Math.min(vh - height - 12, (vh - height) / 2 - 40 + step))}px`;
        layer.append(w);
        front(w);
        body.focus({ preventScroll: true });

        const close = () => {
          w.classList.add("closing");
          setTimeout(() => w.remove(), still.matches ? 0 : 160);
          icon.focus({ preventScroll: true });
        };
        w.addEventListener("keydown", (e) => {
          if (e.key === "Escape") close();
        });
        w.addEventListener("pointerdown", () => front(w));
        w.querySelector(".l-close").addEventListener("click", close);
        // Rolling up and zooming spring between sizes (.win.morph); dragging stays direct.
        const morph = (change) => {
          w.classList.add("morph");
          change();
          clearTimeout(Number(w.dataset.morph));
          w.dataset.morph = String(setTimeout(() => w.classList.remove("morph"), 600));
        };
        w.querySelector(".l-shade").addEventListener("click", () => morph(() => w.classList.toggle("shaded")));
        w.querySelector(".l-zoom").addEventListener("click", () =>
          morph(() => {
            w.classList.remove("shaded");
            w.classList.toggle("max");
          }),
        );

        const bar = w.querySelector(".win-bar");
        bar.addEventListener("pointerdown", (e) => {
          if (e.button !== 0 || e.target.closest("button") || w.classList.contains("max")) return;
          const x = w.offsetLeft;
          const y = w.offsetTop;
          track(w, e, (dx, dy) => {
            const maxX = document.documentElement.clientWidth - 80;
            w.style.left = `${Math.min(maxX, Math.max(80 - w.offsetWidth, x + dx))}px`;
            w.style.top = `${Math.min(window.innerHeight - 40, Math.max(0, y + dy))}px`;
          });
        });
        bar.addEventListener("dblclick", (e) => {
          if (!e.target.closest("button")) morph(() => w.classList.toggle("max"));
        });
        w.querySelector(".win-grip").addEventListener("pointerdown", (e) => {
          if (e.button !== 0) return;
          e.preventDefault();
          const x = w.offsetWidth;
          const y = w.offsetHeight;
          track(w, e, (dx, dy) => {
            w.style.width = `${Math.max(260, x + dx)}px`;
            w.style.height = `${Math.max(160, y + dy)}px`;
          });
        });
      }

      const select = (icon) => {
        for (const i of icons) i.classList.toggle("sel", i === icon);
      };
      for (const icon of icons) {
        icon.addEventListener("click", (e) => {
          e.preventDefault();
          // Keyboard (Enter) and touch open straight away; a mouse selects first, like a desktop.
          const touch = e.pointerType === "touch" || e.pointerType === "pen";
          if (e.detail === 0 || touch || e.detail >= 2) open(icon);
          else select(icon);
        });
      }
      document.addEventListener("pointerdown", (e) => {
        if (!e.target.closest("a.desk-icon")) select(null);
      });

      // ---------- the clock on the tool strip ----------
      const clock = document.querySelector("[data-clock]");
      const tick = () => {
        const hm = new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
        clock.textContent = hm;
        clock.dateTime = hm;
      };
      tick();
      clock.hidden = false;
      setInterval(tick, 15_000);
    </script>
  </body>
</html>
```
