ZhuoQi Devzhuoqidev.comRSSllms.txt

← All entries~/design/desk-home

Pages & sectionsOriginal

Desktop-Style Homepage

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

html · css · javascriptMITAdded #desktop metaphor#draggable#windows#handmade

Live demoOpen in a new tab ↗

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.
$For agents: index.md

Source MIT licensed. Take it.

<!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>

Say hi on WeChat

Liu ZhuoQi's WeChat QR code

Scan it in WeChat, or long-press it on your phone

Mention why you're reaching out, or I might miss it.