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