breadlock/design/sketch.html
Breadway f0b66cd791 Breadlock UI update: motion system, depth polish, and new animations
Adds a full motion system to the lock screen (staggered entrance, dot pop
+ caret, wrong-password shake, success flash, clock crossfade), depth
polish (gradient veil, pill shadow + hairline border, "Enter password"
hint, responsive typography), and a second wave of animations (idle
breathing, success dot cascade, status slide-in, parallax unlock drift,
animated checking ellipsis, opt-in Ken Burns wallpaper pan). Includes a
dev-only breadlock-preview harness that renders every state to PNGs with
no Wayland involved, and a design/sketch.html prototype.

Fixes text alpha being dropped by cosmic-text's glyph-mask path (all text
fades now work) and double-drifting that made the date/status overlap the
clock/pill during unlock.
2026-08-21 13:12:31 +08:00

661 lines
34 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>breadlock × breadgreet — style &amp; motion sketch</title>
<style>
/* ============================================================
Design tokens — mirrors bread-theme (BREAD_DESIGN_SYSTEM.md)
bg/surface/overlay/foreground are the fixed BOS dark base;
color1-6 are pywal-derived accents. Two palettes shown:
"bread" = curated bread-toned defaults (fresh install)
"tokyo" = an example pywal palette (Tokyo Night)
============================================================ */
:root, [data-palette="bread"] {
--bg: #0c0c0c;
--surface: #1a1a1a;
--surface2: #232323;
--overlay: #d8d8d8;
--fg: #e8e8e8;
--red: #b98749; /* color1 */
--green: #cd9450; /* color2 */
--yellow: #e3a85c; /* color3 */
--accent: #eab672; /* color4 */
--pink: #f6c477; /* color5 */
--teal: #eabe82; /* color6 */
--radius: 8px; /* primary */
--radius-sm: 6px; /* secondary (inputs) */
--font: "Varela Round", "Segoe UI", system-ui, -apple-system, sans-serif;
--line: #2b2b2b;
}
[data-palette="tokyo"] {
--red: #f7768e;
--green: #9ece6a;
--yellow: #e0af68;
--accent: #7aa2f7;
--pink: #bb9af7;
--teal: #7dcfff;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
padding: 32px clamp(16px, 4vw, 48px) 80px;
}
header.maxw, main { max-width: 1240px; margin: 0 auto; }
header h1 { font-size: 26px; margin: 0 0 6px; letter-spacing: .2px; }
header p.sub { color: var(--overlay); opacity: .72; margin: 0 0 18px; max-width: 72ch; }
header .meta {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px;
}
.palette-toggle {
display: inline-flex; border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden; margin-left: auto;
}
.palette-toggle button {
background: transparent; color: var(--overlay); border: 0; padding: 6px 14px;
font: inherit; font-size: 12px; cursor: pointer;
}
.palette-toggle button.active { background: var(--surface2); color: var(--fg); }
.swatches { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.swatch { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--overlay); opacity: .85; }
.swatch i { width: 14px; height: 14px; border-radius: 4px; border: 1px solid rgba(255,255,255,.15); display: inline-block; }
h2 { font-size: 16px; margin: 34px 0 4px; }
h2 small { color: var(--overlay); opacity: .6; font-weight: 400; margin-left: 8px; }
.hint { color: var(--overlay); opacity: .7; font-size: 12px; margin: 0 0 14px; max-width: 90ch; }
/* ---------- stages ---------- */
.stages { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 960px) { .stages { grid-template-columns: 1fr; } }
.stage {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
padding: 14px; display: flex; flex-direction: column; gap: 12px;
}
.stage > .stage-head { display: flex; align-items: baseline; gap: 8px; }
.stage > .stage-head h3 { margin: 0; font-size: 14px; }
.stage > .stage-head span { font-size: 11px; color: var(--overlay); opacity: .6; }
.stage .states { display: flex; flex-wrap: wrap; gap: 6px; }
.stage .states button {
background: var(--surface2); color: var(--overlay); border: 1px solid var(--line);
border-radius: 999px; padding: 4px 12px; font: inherit; font-size: 11px; cursor: pointer;
}
.stage .states button.active { background: var(--accent); color: #0c0c0c; border-color: transparent; font-weight: 700; }
/* the "monitor" */
.screen {
position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: var(--radius-sm);
overflow: hidden; border: 1px solid var(--line); background: var(--bg);
}
.wallpaper, .veil { position: absolute; inset: 0; }
[data-palette="bread"] .wallpaper {
background:
radial-gradient(120% 90% at 18% 8%, #3b2f1e 0%, transparent 55%),
radial-gradient(100% 100% at 88% 88%, #2c2313 0%, transparent 60%),
linear-gradient(160deg, #17130c 0%, #0c0c0c 72%);
}
[data-palette="tokyo"] .wallpaper {
background:
radial-gradient(120% 90% at 18% 8%, #2b3152 0%, transparent 55%),
radial-gradient(100% 100% at 88% 88%, #1c2338 0%, transparent 60%),
linear-gradient(160deg, #10121c 0%, #0c0c0c 72%);
}
.veil { background: rgba(0, 0, 0, .28); } /* DIM_ALPHA = 0.28, fades with chrome */
/* ---------- breadlock overlay ---------- */
.lockoverlay {
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
padding-top: 12%; transition: opacity 400ms ease, transform 400ms ease;
}
.screen[data-state="unlock"] .lockoverlay { opacity: 0; transform: translateY(-20px); }
.lclock {
font-size: clamp(34px, 7.5vw, 60px); font-weight: 700; color: #fff; text-align: center;
animation: riseIn 450ms ease-out both;
}
.ldate {
font-size: clamp(11px, 1.6vw, 14px); color: rgba(255,255,255,.82); margin-top: 6px;
animation: riseIn 450ms ease-out 80ms both;
}
.badge-new {
display: inline-block; vertical-align: middle; margin-left: 6px; padding: 1px 6px;
font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase;
border-radius: 4px; background: var(--accent); color: #0c0c0c;
}
.pill {
position: relative; margin-top: clamp(20px, 5vh, 44px);
width: clamp(190px, 36vw, 280px); height: clamp(34px, 6vw, 48px);
border-radius: var(--radius-sm);
background: var(--surface);
border: 1px solid rgba(255,255,255,.08);
display: flex; align-items: center; justify-content: center; gap: clamp(8px, 1.6vw, 18px);
animation: popIn 380ms cubic-bezier(.34, 1.56, .64, 1) 100ms both;
transition: background-color 150ms ease, border-color 150ms ease;
box-shadow: 0 4px 18px rgba(0,0,0,.45);
}
.dot {
width: clamp(6px, 1.1vw, 9px); height: clamp(6px, 1.1vw, 9px); border-radius: 50%;
background: var(--accent); animation: dotPop 200ms ease-out both;
}
.pill .dot:nth-child(1) { animation-delay: 150ms; }
.pill .dot:nth-child(2) { animation-delay: 200ms; }
.pill .dot:nth-child(3) { animation-delay: 250ms; }
.caret {
width: 2px; height: 1.2em; background: var(--accent); border-radius: 1px;
animation: caretBlink 1.1s steps(1) infinite; opacity: .9;
}
.lstatus { margin-top: 12px; font-size: 12px; color: var(--overlay); min-height: 1em; text-align: center; }
/* wrong password: shake + red, then fade back */
.screen[data-state="wrong"] .pill {
background: var(--red); border-color: transparent;
animation: shake 380ms cubic-bezier(.36,.07,.19,.97);
}
.screen[data-state="wrong"] .dot, .screen[data-state="wrong"] .caret { background: #fff; }
.screen[data-state="wrong"] .lstatus { color: var(--red); font-weight: 700; }
/* success: green flash, then the unlock fade is handled by data-state="unlock" */
.screen[data-state="success"] .pill { background: var(--green); border-color: transparent; animation: successFlash 300ms ease-out; }
.screen[data-state="success"] .dot, .screen[data-state="success"] .caret { background: #fff; }
.screen[data-state="success"] .lstatus { color: var(--green); font-weight: 700; }
.replay {
align-self: flex-start; background: transparent; color: var(--accent); border: 1px solid var(--line);
border-radius: var(--radius-sm); padding: 4px 12px; font: inherit; font-size: 11px; cursor: pointer;
}
.replay:hover { border-color: var(--accent); }
/* ---------- breadgreet ---------- */
.greetoverlay {
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 18px;
}
.gclock { font-size: clamp(28px, 5vw, 44px); font-weight: 700; color: #fff; animation: riseIn 450ms ease-out both; }
.card {
width: clamp(240px, 44vw, 340px); background: var(--surface);
border: 1px solid rgba(255,255,255,.08); border-radius: var(--radius);
padding: 20px; display: flex; flex-direction: column; gap: 10px;
animation: riseIn 450ms ease-out 120ms both;
box-shadow: 0 6px 24px rgba(0,0,0,.5);
}
.gentry {
width: 100%; background: var(--surface2); color: var(--fg);
border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 10px 14px; font: inherit; font-size: 14px;
transition: border-color 200ms ease, box-shadow 200ms ease;
}
.gentry:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(234,182,114,.25); }
[data-palette="tokyo"] .gentry:focus { box-shadow: 0 0 0 2px rgba(122,162,247,.28); }
.gstatus { font-size: 12px; color: var(--overlay); opacity: .75; min-height: 1em; text-align: center; transition: opacity 200ms; }
.screen[data-state="error"] .gstatus { color: var(--red); opacity: 1; font-weight: 700; }
.spinner {
margin: 0 auto; width: 18px; height: 18px; border-radius: 50%;
border: 2px solid rgba(255,255,255,.15); border-top-color: var(--accent);
animation: spin .8s linear infinite; display: none;
}
.screen[data-state="checking"] .spinner { display: block; }
.srow {
display: flex; align-items: center; gap: 10px; width: 100%;
background: var(--surface2); border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 8px 12px; font-size: 12px; color: var(--overlay);
}
.srow .icon {
width: 20px; height: 20px; border-radius: 5px; flex: none;
background: linear-gradient(135deg, var(--accent), var(--teal));
}
.srow .label { flex: 1; text-align: left; }
.srow .chev { color: var(--overlay); opacity: .6; }
.screen[data-state="error"] .card { animation: shake 380ms cubic-bezier(.36,.07,.19,.97); }
/* ---------- keyframes ---------- */
@keyframes riseIn { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: none; } }
@keyframes popIn { from { opacity: 0; transform: scale(.94); } 70% { transform: scale(1.02); } to { opacity: 1; transform: scale(1); } }
@keyframes dotPop { from { transform: scale(0); } 70% { transform: scale(1.35); } to { transform: scale(1); } }
@keyframes caretBlink { 0%, 55% { opacity: .9; } 56%, 100% { opacity: 0; } }
@keyframes shake {
10%, 90% { transform: translateX(-2px); } 20%, 80% { transform: translateX(5px); }
30%, 50%, 70% { transform: translateX(-8px); } 40%, 60% { transform: translateX(8px); }
}
@keyframes successFlash { from { box-shadow: 0 0 0 0 rgba(205,148,80,.55); } to { box-shadow: 0 0 0 22px rgba(205,148,80,0); } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes breathe { 0%, 100% { box-shadow: 0 4px 18px rgba(0,0,0,.45); } 50% { box-shadow: 0 4px 26px rgba(0,0,0,.6), 0 0 0 1px rgba(234,182,114,.12); } }
@keyframes clockFlip { 0% { opacity: 1; } 45% { opacity: 0; transform: translateY(6px); } 55% { opacity: 0; transform: translateY(-6px); } 100% { opacity: 1; transform: none; } }
@keyframes kbdPan { 0% { transform: translateX(-2.5%) scale(1.06); } 100% { transform: translateX(2.5%) scale(1.06); } }
/* ---------- motion library ---------- */
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.tile {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
padding: 12px; display: flex; flex-direction: column; gap: 8px;
}
.tile .tname { font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 6px; }
.tile .tag { font-size: 9px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; padding: 1px 6px; border-radius: 4px; background: var(--surface2); color: var(--overlay); }
.tile .tag.S { color: var(--green); } .tile .tag.M { color: var(--yellow); } .tile .tag.L { color: var(--red); }
.tile .tnote { font-size: 11px; color: var(--overlay); opacity: .75; min-height: 3em; }
.tile .tscreen {
position: relative; width: 100%; aspect-ratio: 16 / 7; border-radius: var(--radius-sm);
overflow: hidden; background: var(--bg); border: 1px solid var(--line);
}
/* tile demos */
.tile .tscreen .tclock { position: absolute; top: 22%; left: 0; right: 0; text-align: center; color: #fff; font-weight: 700; font-size: 22px; }
.tile .tscreen .tpill {
position: absolute; top: 52%; left: 50%; transform: translateX(-50%);
width: 120px; height: 26px; border-radius: var(--radius-sm); background: var(--surface);
border: 1px solid rgba(255,255,255,.08); display: flex; align-items: center; justify-content: center; gap: 9px;
}
.tile .tscreen .tpill i { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
.tile .tscreen .tpill .tc { width: 2px; height: 12px; border-radius: 1px; background: var(--accent); animation: caretBlink 1.1s steps(1) infinite; }
.tile[data-tile="stagger"] .tclock, .tile[data-tile="stagger"] .tpill { animation: riseIn 450ms ease-out both; }
.tile[data-tile="stagger"] .tpill { animation-name: popIn; animation-delay: 140ms; }
.tile[data-tile="dotpop"] .tpill i:nth-child(1) { animation: dotPop 200ms ease-out 120ms both; }
.tile[data-tile="dotpop"] .tpill i:nth-child(2) { animation: dotPop 200ms ease-out 180ms both; }
.tile[data-tile="dotpop"] .tpill i:nth-child(3) { animation: dotPop 200ms ease-out 240ms both; }
.tile[data-tile="shake"] .tpill { background: var(--red); animation: shake 380ms cubic-bezier(.36,.07,.19,.97) 200ms both; }
.tile[data-tile="shake"] .tpill i { background: #fff; }
.tile[data-tile="shake"] .tstatus { position: absolute; top: 66%; width: 100%; text-align: center; font-size: 10px; color: var(--red); opacity: 0; animation: fadeIn 200ms ease 380ms both; }
.tile[data-tile="flash"] .tpill { background: var(--green); animation: successFlash 300ms ease-out 200ms both; }
.tile[data-tile="flash"] .tpill i { background: #fff; }
.tile[data-tile="crossfade"] .tclock .old, .tile[data-tile="crossfade"] .tclock .new {
position: absolute; inset: 0; transition: opacity 300ms ease, transform 300ms ease;
}
.tile[data-tile="crossfade"] .tclock .new { opacity: 0; transform: translateY(6px); }
.tile[data-tile="crossfade"].ticked .tclock .old { opacity: 0; transform: translateY(-6px); }
.tile[data-tile="crossfade"].ticked .tclock .new { opacity: 1; transform: none; }
.tile[data-tile="breathe"] .tpill { animation: breathe 3.2s ease-in-out infinite; }
.tile[data-tile="gcard"] .gcard-mini {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 130px; background: var(--surface); border: 1px solid rgba(255,255,255,.08);
border-radius: var(--radius); padding: 10px; animation: riseIn 450ms ease-out both;
}
.tile[data-tile="gcard"] .gcard-mini b { display: block; height: 12px; border-radius: 4px; background: var(--surface2); }
.tile[data-tile="gcard"] .gcard-mini b + b { margin-top: 6px; height: 8px; opacity: .6; }
.tile[data-tile="focus"] .gcard-mini b:first-child { transition: border-color 200ms, box-shadow 200ms; border: 1px solid var(--line); }
.tile[data-tile="focus"].focused .gcard-mini b:first-child { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(234,182,114,.25); }
.tile[data-tile="spinner"] .spin-mini {
position: absolute; top: 50%; left: 50%; width: 20px; height: 20px; margin: -10px 0 0 -10px;
border-radius: 50%; border: 2px solid rgba(255,255,255,.15); border-top-color: var(--accent);
animation: spin .8s linear infinite;
}
.tile[data-tile="session"] .rows { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 140px; display: flex; flex-direction: column; gap: 5px; }
.tile[data-tile="session"] .rows div {
display: flex; align-items: center; gap: 7px; background: var(--surface2);
border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 5px 8px; font-size: 10px; color: var(--overlay);
}
.tile[data-tile="session"] .rows div i { width: 12px; height: 12px; border-radius: 3px; background: linear-gradient(135deg, var(--accent), var(--teal)); }
.tile[data-tile="session"] .rows div.sel { border-color: var(--accent); color: var(--fg); }
.tile[data-tile="kenburns"] .tpill { opacity: 0; }
.tile[data-tile="kenburns"] .tscreen.noanim::after { animation: none; }
.tile[data-tile="kenburns"] .tscreen::after {
content: ""; position: absolute; inset: -8%;
background: radial-gradient(120% 90% at 18% 8%, #3b2f1e 0%, transparent 55%),
radial-gradient(100% 100% at 88% 88%, #2c2313 0%, transparent 60%),
linear-gradient(160deg, #17130c 0%, #0c0c0c 72%);
animation: kbdPan 9s ease-in-out infinite alternate;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* ---------- implementation notes ---------- */
.notes { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 960px) { .notes { grid-template-columns: 1fr; } }
.notes .col { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; }
.notes h4 { margin: 0 0 10px; font-size: 13px; }
.notes ul { margin: 0; padding-left: 18px; font-size: 12px; color: var(--overlay); opacity: .9; }
.notes li { margin-bottom: 8px; }
.notes code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; color: var(--accent); opacity: .9; }
footer { margin-top: 40px; color: var(--overlay); opacity: .55; font-size: 11px; max-width: 100ch; }
</style>
</head>
<body data-palette="bread">
<header class="maxw">
<div class="meta">
<h1>breadlock × breadgreet — style &amp; motion sketch</h1>
<div class="palette-toggle" id="paletteToggle">
<button data-palette="bread" class="active">Bread default</button>
<button data-palette="tokyo">Pywal (Tokyo Night)</button>
</div>
</div>
<p class="sub">
Live CSS prototype of the lock screen and greeter, grounded in the real
<code>bread-theme</code> tokens (fixed BOS dark base + pywal accents). The locker's
software renderer (tiny-skia) can reproduce every motion here; the greeter uses the
same CSS engine directly (GTK4). <b>Badges</b> mark what already ships vs what's proposed.
</p>
<div class="swatches">
<span class="swatch"><i style="background:#0c0c0c"></i>bg</span>
<span class="swatch"><i style="background:#1a1a1a"></i>surface</span>
<span class="swatch"><i id="swRed" style="background:var(--red)"></i>red</span>
<span class="swatch"><i id="swGreen" style="background:var(--green)"></i>green</span>
<span class="swatch"><i id="swAccent" style="background:var(--accent)"></i>accent</span>
</div>
</header>
<main>
<h2>Live stages <small>click a state chip to replay it</small></h2>
<p class="hint">The two apps should feel like one family: same palette, same radius/spacing tokens, same motion language (ease-out, 300450ms).</p>
<div class="stages">
<!-- ================= breadlock ================= -->
<section class="stage">
<div class="stage-head"><h3>breadlock</h3><span>tiny-skia · 16ms timer loop · <code>render.rs</code></span></div>
<div class="screen" id="lockScreen" data-state="idle">
<div class="wallpaper"></div>
<div class="veil"></div>
<div class="lockoverlay">
<div class="lclock">21:47<span class="badge-new">date</span><div class="ldate">Friday · Aug 21</div></div>
<div class="pill">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="caret" title="proposed"></span>
</div>
<div class="lstatus" id="lockStatus"></div>
</div>
</div>
<div class="states" data-screen="lockScreen">
<button data-state="idle" class="active">Idle</button>
<button data-state="typing">Typing</button>
<button data-state="wrong">Wrong pw</button>
<button data-state="success">Success</button>
<button data-state="unlock">Unlock</button>
</div>
<button class="replay" data-replay="lockScreen">Replay entrance</button>
</section>
<!-- ================= breadgreet ================= -->
<section class="stage">
<div class="stage-head"><h3>breadgreet</h3><span>GTK4 · relm4 · CSS in <code>theme.rs</code></span></div>
<div class="screen" id="greetScreen" data-state="username">
<div class="wallpaper"></div>
<div class="veil"></div>
<div class="greetoverlay">
<div class="gclock">21:47</div>
<div class="card">
<input class="gentry" id="greetEntry" type="text" placeholder="Username" />
<div class="gstatus" id="greetStatus"></div>
<div class="spinner"></div>
<div class="srow">
<span class="icon"></span>
<span class="label">bos — Hyprland <span class="badge-new">icon</span></span>
<span class="chev"></span>
</div>
</div>
</div>
</div>
<div class="states" data-screen="greetScreen">
<button data-state="username" class="active">Username</button>
<button data-state="prompt">Password prompt</button>
<button data-state="checking">Checking…</button>
<button data-state="error">Wrong pw</button>
</div>
<button class="replay" data-replay="greetScreen">Replay entrance</button>
</section>
</div>
<h2>Motion library <small>proposed animations, mapped to where each lands</small></h2>
<p class="hint">Every idea below is prototypeable in CSS first, then ported. Effort: <b style="color:var(--green)">S</b> small ·
<b style="color:var(--yellow)">M</b> medium · <b style="color:var(--red)">L</b> large (protocol/CPU work).</p>
<div class="tiles">
<div class="tile" data-tile="stagger">
<div class="tname">Entrance stagger <span class="tag S">S</span></div>
<div class="tscreen"><div class="tclock">21:47</div><div class="tpill"><i></i><i></i><i></i></div></div>
<div class="tnote">Clock → pill → status cascade instead of one uniform fade. Pill overshoots ~2% (ease-out-back). Replaces the single overlay motion in <code>render.rs</code>.</div>
<button class="replay" data-replay="tile-stagger">Replay</button>
</div>
<div class="tile" data-tile="dotpop">
<div class="tname">Dot pop + caret <span class="tag S">S</span></div>
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i><span class="tc"></span></div></div>
<div class="tnote">Newest password dot scales in with overshoot; a blinking caret marks where you're typing. Today dots just appear — <code>render.rs</code> dot loop.</div>
<button class="replay" data-replay="tile-dotpop">Replay</button>
</div>
<div class="tile" data-tile="shake">
<div class="tname">Wrong-password shake <span class="tag S">S</span></div>
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i></div><div class="tstatus">Wrong password</div></div>
<div class="tnote">The classic, currently reserved for v2 — failure is just a red pill today. Damped 8px shake, red fill, auto-clear after <code>fail_timeout_ms</code>.</div>
<button class="replay" data-replay="tile-shake">Replay</button>
</div>
<div class="tile" data-tile="flash">
<div class="tname">Success flash → unlock drift <span class="tag S">S</span></div>
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i></div></div>
<div class="tnote">Correct password: green (<code>color2</code>) flash + glow ring, then the existing 400ms fade-and-drift-up unlock in <code>state.rs</code>.</div>
<button class="replay" data-replay="tile-flash">Replay</button>
</div>
<div class="tile" data-tile="crossfade">
<div class="tname">Clock minute crossfade <span class="tag S">S</span></div>
<div class="tscreen"><div class="tclock"><span class="old">21:47</span><span class="new">21:48</span></div></div>
<div class="tnote">300ms dip-and-swap on the minute tick instead of a hard blink. Locker: crossfade layer in <code>render.rs</code>. Greeter: GTK CSS transition.</div>
<button class="replay" data-replay="tile-crossfade">Tick</button>
</div>
<div class="tile" data-tile="breathe">
<div class="tname">Idle breathing <span class="tag S">S</span></div>
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i></div></div>
<div class="tnote">Very subtle 34s sine on the pill's glow — proof the screen is live, not frozen. Cheap in <code>render.rs</code>; keep amplitude tiny (CPU is software-rendered).</div>
<button class="replay" data-replay="tile-breathe">Replay</button>
</div>
<div class="tile" data-tile="gcard">
<div class="tname">Greeter card entrance <span class="tag S">S</span></div>
<div class="tscreen"><div class="gcard-mini"><b></b><b></b></div></div>
<div class="tnote">Greeter currently has zero animation. Fade + 18px rise, staggered after the clock — GTK4 CSS <code>@keyframes</code> in <code>breadgreet/theme.rs</code>.</div>
<button class="replay" data-replay="tile-gcard">Replay</button>
</div>
<div class="tile" data-tile="focus">
<div class="tname">Entry focus ring <span class="tag S">S</span></div>
<div class="tscreen"><div class="gcard-mini"><b></b><b></b></div></div>
<div class="tnote">Accent border + soft glow on focus, 200ms transition. Standard GTK CSS <code>:focus</code> — matches the shared stylesheet's "blue on focus" input rule.</div>
<button class="replay" data-replay="tile-focus">Focus</button>
</div>
<div class="tile" data-tile="spinner">
<div class="tname">Auth spinner <span class="tag S">S</span></div>
<div class="tscreen"><div class="spin-mini"></div></div>
<div class="tnote">Real <code>gtk::Spinner</code> during <code>Stage::Working</code> instead of static "Checking…" text. One widget swap in <code>breadgreet/main.rs</code>.</div>
<button class="replay" data-replay="tile-spinner">Replay</button>
</div>
<div class="tile" data-tile="session">
<div class="tname">Session icon rows <span class="tag M">M</span></div>
<div class="tscreen"><div class="rows"><div class="sel"><i></i>bos — Hyprland</div><div><i></i>Hyprland</div></div></div>
<div class="tnote"><code>sessions.rs</code> doesn't parse <code>Icon=</code> today. Custom dropdown rows with per-session icons; falls back to a letter tile.</div>
<button class="replay" data-replay="tile-session">Replay</button>
</div>
<div class="tile" data-tile="kenburns">
<div class="tname">Wallpaper Ken Burns <span class="tag M">M</span></div>
<div class="tscreen"><div class="tpill"><i></i></div></div>
<div class="tnote">Slow pan on image wallpapers — just a drifting <code>Transform</code> in <code>background.rs::paint</code>, no new protocol. Gate behind config (CPU cost).</div>
<button class="replay" data-replay="tile-kenburns">Replay</button>
</div>
</div>
<div class="notes">
<div class="col">
<h4>breadlock — where things land</h4>
<ul>
<li><b>Motion</b>: extend the existing <code>anim_timer</code>/<code>tick_animation</code> loop in <code>state.rs</code>; add per-element progress fields (appear started per element, fail-shake start, dot-pop start).</li>
<li><b>Frame math</b>: all easing lives in <code>render.rs</code> (<code>ease_out_cubic</code>, <code>overlay_motion</code>). Add <code>ease_out_back</code> for the pill overshoot and a damped sinusoid for the shake.</li>
<li><b>Success flash</b>: reuse <code>unlocking: Option&lt;Instant&gt;</code> — flash phase 0250ms, fade 250650ms, then <code>unlock()</code>.</li>
<li><b>Bigger</b>: live blur-of-desktop needs a <code>wlr-screencopy</code> capture (already flagged v2 in README) — software downscale → blur → upscale to keep CPU sane. New <code>[animation]</code> config section (enabled / speed / per-effect toggles).</li>
</ul>
</div>
<div class="col">
<h4>breadgreet — where things land</h4>
<ul>
<li><b>Motion</b>: GTK4 CSS supports <code>@keyframes</code>/<code>animation</code> and transitions — everything goes in <code>breadgreet/theme.rs::load_css</code>, no Rust logic needed for entrance/focus/status.</li>
<li><b>Spinner</b>: swap the status label for a <code>gtk::Spinner</code> during <code>Stage::Working</code> in <code>main.rs</code>.</li>
<li><b>Session icons</b>: parse <code>Icon=</code> in <code>sessions.rs</code> and switch <code>DropDown</code> to custom rows.</li>
<li><b>Unify with the locker</b>: same clock sizing/weight, same radius + spacing tokens; card <code>backdrop-filter: blur()</code> if GTK ≥ 4.12 supports it (README already requires 4.12).</li>
</ul>
</div>
</div>
<footer>
Sketch mirrors <code>breadlock/src/render.rs</code> + <code>state.rs</code>, <code>breadgreet/src/theme.rs</code> + <code>main.rs</code>, and the tokens in
<code>bread-ecosystem/BREAD_DESIGN_SYSTEM.md</code> / <code>bread-theme/src/palette.rs</code>. Palette: fixed BOS dark base
(bg <code>#0c0c0c</code>, surface <code>#1a1a1a</code>, overlay <code>#d8d8d8</code>) with pywal-driven accents (color16).
The "bread" palette's red/green/accent are the curated bread-toned defaults, which is why "wrong password" is brownish until pywal is active.
</footer>
</main>
<script>
/* Palette toggle */
const toggle = document.getElementById("paletteToggle");
toggle.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
document.body.dataset.palette = btn.dataset.palette;
toggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
});
/* Restart a stylesheet-driven CSS animation: drop the animation via an
inline override, force a reflow, then remove the override so the rule
applies again from its first frame. */
function replayAnim(el) {
el.style.animation = "none";
void el.offsetWidth;
el.style.animation = "";
}
/* ---- breadlock stage ---- */
const lockScreen = document.getElementById("lockScreen");
const lockStatus = document.getElementById("lockStatus");
function setLockState(state) {
// The attribute change is what starts each CSS animation; bounce through
// idle so repeat clicks on the same chip replay it.
if (state === "wrong" || state === "success" || state === "unlock") {
lockScreen.dataset.state = "idle";
void lockScreen.offsetWidth;
}
lockScreen.dataset.state = state;
document.querySelectorAll('[data-screen="lockScreen"] button').forEach((b) =>
b.classList.toggle("active", b.dataset.state === state));
switch (state) {
case "typing":
lockScreen.querySelectorAll(".dot").forEach(replayAnim);
lockStatus.textContent = "";
break;
case "wrong":
lockStatus.textContent = "Wrong password";
setTimeout(() => { if (lockScreen.dataset.state === "wrong") setLockState("idle"); }, 1200);
break;
case "success":
lockStatus.textContent = "✓ Unlocked";
setTimeout(() => setLockState("unlock"), 600);
break;
case "unlock":
setTimeout(() => { setLockState("idle"); replayLockEntrance(); }, 900);
break;
default:
lockStatus.textContent = "";
}
}
function replayLockEntrance() {
replayAnim(lockScreen.querySelector(".lclock"));
replayAnim(lockScreen.querySelector(".ldate"));
replayAnim(lockScreen.querySelector(".pill"));
}
document.querySelectorAll('[data-screen="lockScreen"] button').forEach((b) =>
b.addEventListener("click", () => setLockState(b.dataset.state)));
document.querySelector('[data-replay="lockScreen"]').addEventListener("click", replayLockEntrance);
/* ---- breadgreet stage ---- */
const greetScreen = document.getElementById("greetScreen");
const greetEntry = document.getElementById("greetEntry");
const greetStatus = document.getElementById("greetStatus");
const greetStates = {
username: { placeholder: "Username", status: "" },
prompt: { placeholder: "Password", status: "Password for breadway" },
checking: { placeholder: "Password", status: "Checking…" },
error: { placeholder: "Password", status: "Wrong password" },
};
function setGreetState(state) {
if (state === "error") {
greetScreen.dataset.state = "username";
void greetScreen.offsetWidth;
}
greetScreen.dataset.state = state;
document.querySelectorAll('[data-screen="greetScreen"] button').forEach((b) =>
b.classList.toggle("active", b.dataset.state === state));
const s = greetStates[state];
greetEntry.placeholder = s.placeholder;
greetStatus.textContent = s.status;
if (state === "error") {
setTimeout(() => { if (greetScreen.dataset.state === "error") setGreetState("prompt"); }, 1200);
} else if (state === "checking") {
setTimeout(() => { if (greetScreen.dataset.state === "checking") setGreetState("username"); }, 1600);
}
}
document.querySelectorAll('[data-screen="greetScreen"] button').forEach((b) =>
b.addEventListener("click", () => setGreetState(b.dataset.state)));
document.querySelector('[data-replay="greetScreen"]').addEventListener("click", () => {
replayAnim(greetScreen.querySelector(".gclock"));
replayAnim(greetScreen.querySelector(".card"));
});
/* ---- motion library ---- */
const tileActions = {
"tile-stagger": (t) => { replayAnim(t.querySelector(".tclock")); replayAnim(t.querySelector(".tpill")); },
"tile-dotpop": (t) => t.querySelectorAll(".tpill i").forEach(replayAnim),
"tile-shake": (t) => { replayAnim(t.querySelector(".tpill")); replayAnim(t.querySelector(".tstatus")); },
"tile-flash": (t) => replayAnim(t.querySelector(".tpill")),
"tile-crossfade": (t) => t.classList.toggle("ticked"),
"tile-breathe": (t) => replayAnim(t.querySelector(".tpill")),
"tile-gcard": (t) => replayAnim(t.querySelector(".gcard-mini")),
"tile-focus": (t) => t.classList.toggle("focused"),
"tile-spinner": () => {},
"tile-session": (t) => replayAnim(t.querySelector(".rows")),
"tile-kenburns": (t) => {
const sc = t.querySelector(".tscreen");
sc.classList.add("noanim");
void sc.offsetWidth;
sc.classList.remove("noanim");
},
};
document.querySelectorAll(".tile").forEach((tile) => {
const btn = tile.querySelector(".replay");
if (!btn) return;
btn.addEventListener("click", () => tileActions["tile-" + tile.dataset.tile]?.(tile));
});
/* Boot the lock stage with the entrance visible. */
replayLockEntrance();
</script>
</body>
</html>