- Geist Pixel type, tapestry board palette, bevelled panels, press-able buttons - Board sizes via container queries; portrait/landscape/square layouts - Rooms-sealed HUD, place/shake/solve animations, synthesized SFX with mute - Result card with rank, time, resets, streak; share button moves there - Title screen with pixel keep scene, stat row, delta mark replacing text brand - Daily streak helper + rank helper with tests; SW cache v3 precaches fonts
- commit
- 91d96409c216cf67dc08dd739b346812ca799c2d
- parent
- 87cf740b1c7ad38522fc1802a9b049dc0a5e8f77
- author
- Dami <dami.etoile@gmail.com>
| M | assets/ | 1244 | ++++++++++++++---------------- |
| A | components/ | 28 | + |
| A | components/ | 74 | ++ |
| M | islands/ | 131 | +-- |
| M | islands/ | 15 | +- |
| D | islands/ | 52 | - |
| A | islands/ | 60 | + |
| M | islands/ | 17 | +- |
| M | islands/ | 290 | +++++-- |
| A | lib/ | 95 | ++ |
| A | lib/ | 16 | + |
| A | lib/ | 26 | + |
| M | lib/ | 2 | +- |
| M | lib/ | 47 | + |
| M | routes/ | 2 | +- |
| M | routes/ | 62 | +- |
| A | static/ | bin | |
| M | static/ | 2 | +- |
| M | static/ | 5 | +- |
| A | tests/ | 17 | + |
| M | tests/ | 34 | + |
@@ -1,3 +1,41 @@
1+/* ---------- fonts (Geist Pixel, vendored, SIL OFL) ---------- */2+@font-face {3+ font-family: "Geist Pixel Square";4+ src: url("/fonts/GeistPixel-Square.woff2") format("woff2");5+ font-weight: 400;6+ font-display: swap;7+}8+9+/* ---------- tokens ---------- */10+:root {11+ --moss-0: #0a120a;12+ --moss-1: #122411;13+ --moss-2: #1f3a1c;14+ --moss-3: #3e5f33;15+ --moss-hi: #2e4a2a;16+ --parch: #f4ebcf;17+ --parch-2: #e6dab0;18+ --parch-hi: #fff8dd;19+ --parch-lo: #b7a97b;20+ --ink: #0b1209;21+ --gold: #f2cb55;22+ --gold-hi: #fff2b8;23+ --gold-lo: #c99a24;24+ --madder: #b8483d;25+ --text-dim: #8ea36f;26+ --text-soft: #cfd8b4;27+ --font-title: "Geist Pixel Square", ui-monospace, Menlo, monospace;28+ --font-pixel: "Geist Pixel Square", ui-monospace, Menlo, monospace;29+ --font-body: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,30+ "Helvetica Neue", Arial, sans-serif;31+ --bevel-parch: inset 2px 2px 0 var(--parch-hi), inset -2px -2px 032+ var(--parch-lo);33+ --bevel-moss: inset 2px 2px 0 var(--moss-hi), inset -2px -2px 0 #050905;34+ --press-shadow: 4px 4px 0 var(--ink);35+ --step: steps(2, end);36+}37+38+/* ---------- reset ---------- */139 *,240 *::before,341 *::after {@@ -9,15 +47,10 @@
947 button {1048 color: inherit;1149 }12-button, [role="button"] {50+button,51+[role="button"] {1352 cursor: pointer;1453 }15-code {16- font-family:17- ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",18- "Courier New", monospace;19- font-size: 1em;20-}2154 img,2255 svg {2356 display: block;@@ -27,18 +60,21 @@ video {
2760 max-width: 100%;2861 height: auto;2962 }63+button,64+input,65+textarea,66+select {67+ font: inherit;68+}3069 3170 html {3271 width: 100%;3372 height: 100%;34- line-height: 1.5;73+ line-height: 1.4;3574 -webkit-text-size-adjust: 100%;36- font-family:37- ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",38- Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,39- "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",40- "Noto Color Emoji";41- background: #f7f4ea;75+ font-family: var(--font-body);76+ background: var(--moss-0);77+ color: var(--parch);4278 overscroll-behavior: none;4379 overflow: hidden;4480 }@@ -49,788 +85,706 @@ body {
4985 margin: 0;5086 position: fixed;5187 inset: 0;52- background: #f7f4ea;88+ background: var(--moss-0);5389 overscroll-behavior: none;5490 overflow: hidden;5591 user-select: none;5692 -webkit-user-select: none;5793 }5894 59-button,60-input,61-textarea,62-select {63- font: inherit;64-}65-66-.shikaku-home {67- position: fixed;68- inset: 0;69- min-height: 100svh;70- height: 100svh;71- width: 100%;72- overflow: hidden;73- background: linear-gradient(180deg, #0f180d 0%, #122411 44%, #0b130b 100%);74- color: #f7f0d8;75- font-family:76- ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",77- "Courier New", monospace;78-}79-80-.shikaku-home__frame {81- width: min(100%, 76rem);82- height: 100%;83- margin: 0 auto;84- display: grid;85- grid-template-rows: auto 1fr;86- gap: 0.8rem;87- padding: max(0.75rem, env(safe-area-inset-top)) 0.75rem88- max(0.75rem, env(safe-area-inset-bottom));95+:focus-visible {96+ outline: 3px solid var(--gold);97+ outline-offset: 2px;8998 }9099 91-.shikaku-home__masthead,92-.shikaku-home__hero {93- border: 2px solid #0b1209;94- background: #f4ebcf;95- color: #11160d;96- box-shadow: 6px 6px 0 #0b1209;100+/* ---------- shared primitives ---------- */101+.px {102+ font-family: var(--font-pixel);103+ letter-spacing: 0.02em;97104 }98105 99-.shikaku-home__masthead {100- display: flex;101- align-items: center;102- justify-content: space-between;103- gap: 1rem;104- padding: 0.75rem 0.9rem;106+.panel {107+ border: 2px solid var(--ink);108+ background: var(--moss-1);109+ color: var(--parch);110+ box-shadow: var(--bevel-moss);105111 }106112 107-.shikaku-home__brand {108- font-size: 0.92rem;109- font-weight: 900;110- letter-spacing: 0.08em;111- text-transform: uppercase;113+.panel--parch {114+ background: var(--parch);115+ color: var(--ink);116+ box-shadow: var(--bevel-parch);112117 }113118 114-.shikaku-home__offline-badge {119+.btn {115120 display: inline-flex;116121 align-items: center;117122 justify-content: center;118- min-height: 2rem;119- padding: 0.35rem 0.6rem;120- border: 2px solid #0b1209;121- background: #f2cb55;122- color: #11160d;123- font-size: 0.72rem;124- font-weight: 900;125- letter-spacing: 0.08em;126- text-transform: uppercase;127- box-shadow: 3px 3px 0 #0b1209;123+ gap: 0.45rem;124+ min-height: 2.6rem;125+ padding: 0.45rem 0.9rem;126+ border: 2px solid var(--ink);127+ background: var(--gold);128+ color: var(--ink);129+ font-family: var(--font-pixel);130+ font-size: 1rem;131+ line-height: 1;132+ text-decoration: none;133+ white-space: nowrap;134+ box-shadow: inset 2px 2px 0 var(--gold-hi), inset -2px -2px 0 var(--gold-lo),135+ var(--press-shadow);136+ transition: transform 60ms var(--step), box-shadow 60ms var(--step);128137 }129-130-.shikaku-home__hero {131- position: relative;132- overflow: hidden;133- display: grid;134- grid-template-columns: 1fr;135- gap: 0.9rem;136- min-height: 0;137- height: 100%;138- padding: 0.9rem;139- background: linear-gradient(180deg, #efe5c2 0%, #dfe6b4 100%);138+.btn:hover {139+ background: #ffd86a;140140 }141-142-.shikaku-home__hero-copy {143- position: relative;144- z-index: 1;145- display: grid;146- gap: 0.85rem;141+.btn:active,142+.btn.is-pressed {143+ transform: translate(3px, 3px);144+ box-shadow: inset 2px 2px 0 var(--gold-hi), inset -2px -2px 0 var(--gold-lo),145+ 1px 1px 0 var(--ink);147146 }148-149-.shikaku-home__eyebrow,150-.shikaku-home__feature-index,151-.shikaku-home__action-kicker {152- font-size: 0.78rem;153- font-weight: 900;154- letter-spacing: 0.08em;155- text-transform: uppercase;147+.btn--ghost {148+ background: var(--parch-2);149+ box-shadow: var(--bevel-parch), var(--press-shadow);156150 }157-158-.shikaku-home__eyebrow {159- display: inline-flex;160- justify-self: start;161- padding: 0.45rem 0.6rem;162- border: 2px solid #0b1209;163- background: #d4deb0;164- box-shadow: 3px 3px 0 #0b1209;151+.btn--ghost:hover {152+ background: var(--parch);165153 }166-167-.shikaku-home__title {168- max-width: 8ch;169- font-size: clamp(2.6rem, 14vw, 6.6rem);170- line-height: 0.85;171- letter-spacing: 0.04em;172- text-transform: uppercase;173- color: #f8f3de;174- text-shadow:175- 2px 0 0 #0b1209,176- -2px 0 0 #0b1209,177- 0 2px 0 #0b1209,178- 0 -2px 0 #0b1209,179- 6px 6px 0 #3e5f33;154+.btn--ghost:active {155+ box-shadow: var(--bevel-parch), 1px 1px 0 var(--ink);180156 }181-182-.shikaku-home__speech-stack {183- display: grid;184- gap: 0.5rem;157+.btn--icon {158+ min-width: 2.6rem;159+ padding: 0.35rem;160+ font-size: 1.15rem;185161 }186-187-.shikaku-home__speech {188- width: fit-content;189- max-width: min(100%, 18rem);190- padding: 0.65rem 0.8rem;191- border: 2px solid #0b1209;192- background: #faf5df;193- font-size: 0.95rem;194- line-height: 1.3;195- box-shadow: 4px 4px 0 #0b1209;162+.btn--sm {163+ min-height: 2.15rem;164+ padding: 0.3rem 0.6rem;165+ font-size: 0.85rem;166+ box-shadow: inset 2px 2px 0 var(--gold-hi), inset -2px -2px 0 var(--gold-lo),167+ 3px 3px 0 var(--ink);196168 }197-198-.shikaku-home__speech--accent {199- background: #bbd083;169+.btn--sm.btn--ghost {170+ box-shadow: var(--bevel-parch), 3px 3px 0 var(--ink);200171 }201-202-.shikaku-home__actions {203- display: grid;204- grid-template-columns: 1fr;205- gap: 0.75rem;206- margin-top: 0.2rem;172+.btn[disabled],173+.btn.is-disabled {174+ opacity: 0.45;175+ pointer-events: none;207176 }208-209-.shikaku-home__action {210- display: grid;211- gap: 0.18rem;212- align-content: center;213- min-height: 4rem;214- padding: 0.8rem 0.95rem;215- border: 2px solid #0b1209;216- text-decoration: none;217- color: #11160d;218- box-shadow: 4px 4px 0 #0b1209;177+.btn__blink {178+ animation: blink 1s steps(1, end) infinite;179+}180+@keyframes blink {181+ 50% {182+ opacity: 0;183+ }219184 }220185 221-.shikaku-home__action strong,222-.shikaku-home__feature-card strong {223- font-size: 1rem;186+.label {187+ font-family: var(--font-pixel);188+ font-size: 0.72rem;189+ letter-spacing: 0.08em;224190 text-transform: uppercase;191+ color: var(--text-dim);225192 }226193 227-.shikaku-home__action--gold {228- background: #f2cb55;194+/* ---------- logo ---------- */195+.delta-mark {196+ overflow: visible;197+ flex-shrink: 0;229198 }230-231-.shikaku-home__action--ghost {232- background: #e2ecc0;199+.delta-mark__ring {200+ fill: none;201+ stroke: #fff;202+ stroke-width: 2;203+ stroke-linecap: round;204+ stroke-linejoin: round;205+}206+.delta-mark--black .delta-mark__ring {207+ stroke: #000;208+}209+.delta-mark__delta {210+ fill: none;211+ stroke: var(--gold);212+ stroke-width: 2;213+ stroke-linecap: round;214+ stroke-linejoin: round;215+}216+.delta-mark--flicker .delta-mark__delta {217+ animation: flick 1.3s steps(1, end) infinite;218+}219+@keyframes flick {220+ 0%,221+ 100% {222+ stroke: var(--gold);223+ }224+ 35% {225+ stroke: #ffe089;226+ }227+ 50% {228+ stroke: var(--gold-lo);229+ }230+ 62% {231+ stroke: #ffe089;232+ }233233 }234234 235-.shikaku-home__hero-art {236- position: relative;237- min-height: 16rem;238- border: 2px solid #0b1209;235+/* =====================================================================236+ HOME — title screen237+ ===================================================================== */238+.home {239+ position: fixed;240+ inset: 0;241+ height: 100svh;242+ overflow: hidden;243+ display: grid;244+ place-items: center;245+ padding: max(0.75rem, env(safe-area-inset-top))246+ max(0.75rem, env(safe-area-inset-right))247+ max(0.75rem, env(safe-area-inset-bottom))248+ max(0.75rem, env(safe-area-inset-left));239249 background: linear-gradient(240250 180deg,241- #7892d5 0 26%,242- #5e7ac4 26% 42%,243- #23301f 42% 100%251+ #1a2a4a 0,252+ #10203a 28%,253+ #0f180d 60%,254+ var(--moss-0) 100%244255 );245- overflow: hidden;246- box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.08);247256 }248-249-.shikaku-home__sky,250-.shikaku-home__lawn-grid,251-.shikaku-home__keep,252-.shikaku-home__moon {253- position: absolute;254-}255-256-.shikaku-home__sky {257- inset: 0;258- background:259- radial-gradient(260- circle at 18% 20%,261- rgba(255, 245, 182, 0.45) 0 7%,262- transparent 8%263- ),264- radial-gradient(265- circle at 76% 14%,266- rgba(255, 255, 255, 0.35) 0 4%,267- transparent 5%268- ),269- radial-gradient(270- circle at 68% 26%,271- rgba(255, 255, 255, 0.2) 0 3%,272- transparent 4%273- ),274- linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 45%);275-}276-277-.shikaku-home__moon {278- top: 1.2rem;279- right: 1.3rem;280- width: 3.3rem;281- aspect-ratio: 1;282- background: #f6efc9;283- box-shadow: 0 0 0 2px #0b1209, 4px 4px 0 rgba(11, 18, 9, 0.9);284-}285-286-.shikaku-home__keep {287- left: 50%;288- bottom: 3.25rem;289- width: min(86%, 20rem);290- height: 9rem;291- transform: translateX(-50%);292-}293-294-.shikaku-home__keep-tower,295-.shikaku-home__keep-wall,296-.shikaku-home__keep-gate,297-.shikaku-home__banner {298- position: absolute;299- border: 2px solid #0b1209;300-}301-302-.shikaku-home__keep-tower,303-.shikaku-home__keep-wall {304- background: #7a7a81;305- box-shadow: inset 0 -12px 0 rgba(0, 0, 0, 0.16);306-}307-308-.shikaku-home__keep-tower::before,309-.shikaku-home__keep-wall::before {257+.home::after {310258 content: "";311259 position: absolute;312260 inset: 0;313- background: linear-gradient(314- 90deg,315- transparent 0 18%,316- rgba(255, 255, 255, 0.18) 18% 26%,317- transparent 26% 100%261+ pointer-events: none;262+ background: repeating-linear-gradient(263+ 0deg,264+ rgba(0, 0, 0, 0.14) 0 2px,265+ transparent 2px 4px318266 );267+ mix-blend-mode: multiply;319268 }320269 321-.shikaku-home__keep-tower--left {322- left: 0;323- bottom: 0;324- width: 3.25rem;325- height: 6rem;270+.home__frame {271+ position: relative;272+ z-index: 1;273+ width: min(100%, 30rem);274+ max-height: 100%;275+ display: grid;276+ grid-template-areas:277+ "scene"278+ "copy";279+ grid-template-rows: minmax(0, 1fr) auto;280+ gap: 1rem;326281 }327282 328-.shikaku-home__keep-tower--center {329- left: 50%;330- bottom: 0;331- width: 4.2rem;332- height: 8rem;333- transform: translateX(-50%);283+.home__scene {284+ grid-area: scene;285+ position: relative;286+ min-height: 0;287+ display: grid;288+ place-items: end center;334289 }335290 336-.shikaku-home__keep-tower--right {337- right: 0;338- bottom: 0;339- width: 3.25rem;340- height: 6.5rem;291+.keep-scene {292+ width: 100%;293+ max-height: 100%;294+ height: auto;295+ image-rendering: pixelated;296+ image-rendering: crisp-edges;341297 }342-343-.shikaku-home__keep-wall {344- left: 2.9rem;345- right: 2.9rem;346- bottom: 0;347- height: 4.25rem;298+.keep-scene__windows {299+ animation: flick 1.3s steps(1, end) infinite;348300 }349-350-.shikaku-home__keep-gate {351- left: 50%;352- bottom: 0;353- width: 3rem;354- height: 2.7rem;355- transform: translateX(-50%);356- background: #171d14;301+.keep-scene__flag {302+ transform-origin: 32px 0;303+ animation: flag 0.8s steps(2, end) infinite;304+}305+@keyframes flag {306+ 50% {307+ transform: scaleX(0.7);308+ }309+}310+.keep-scene__stars rect:nth-child(odd) {311+ animation: blink 2.6s steps(1, end) infinite;312+}313+.keep-scene__stars rect:nth-child(even) {314+ animation: blink 3.4s steps(1, end) infinite reverse;357315 }358316 359-.shikaku-home__banner {360- top: 1.4rem;317+.home__banner {318+ position: absolute;319+ top: 0;361320 width: 1.6rem;362- height: 2.1rem;363- display: grid;364- place-items: center;365- background: #f2cb55;366- color: #11160d;367- font-size: 0.92rem;368- font-weight: 900;321+ height: 2.4rem;322+ background: var(--madder);323+ border: 2px solid var(--ink);324+ clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 78%, 0 100%);325+ animation: flap 0.8s steps(2, end) infinite;326+}327+.home__banner--left {328+ left: 0.5rem;329+}330+.home__banner--right {331+ right: 0.5rem;332+ animation-delay: 0.4s;333+}334+@keyframes flap {335+ 50% {336+ transform: skewX(-6deg);337+ }369338 }370339 371-.shikaku-home__banner--left {372- left: 3.5rem;340+.home__copy {341+ grid-area: copy;342+ display: grid;343+ gap: 0.9rem;344+ justify-items: center;345+ text-align: center;373346 }374347 375-.shikaku-home__banner--right {376- right: 3.5rem;348+.home__title {349+ font-family: var(--font-title);350+ font-weight: 400;351+ font-size: clamp(2.2rem, 11vw, 3.6rem);352+ line-height: 1;353+ color: var(--gold);354+ text-shadow: 3px 3px 0 var(--ink);355+ text-wrap: balance;377356 }378357 379-.shikaku-home__lawn-grid {380- left: 0;381- right: 0;382- bottom: 0;383- height: 4.4rem;384- background:385- repeating-linear-gradient(90deg, #426231 0 0.9rem, #4e7138 0.9rem 1.8rem),386- repeating-linear-gradient(387- 0deg,388- rgba(255, 255, 255, 0.08) 0 2px,389- transparent 2px 1.15rem390- );391- border-top: 2px solid #0b1209;358+.home__stats {359+ display: flex;360+ flex-wrap: wrap;361+ justify-content: center;362+ gap: 0.4rem 1.2rem;363+ font-family: var(--font-pixel);364+ font-size: 0.95rem;365+ color: var(--text-soft);366+}367+.home__stats b {368+ font-weight: 400;369+ color: var(--parch);392370 }393371 394-.shikaku-home__feature-strip {395- display: grid;396- grid-template-columns: 1fr;372+.home__actions {373+ display: flex;374+ flex-wrap: wrap;375+ justify-content: center;397376 gap: 0.75rem;398- padding: 1rem;399- background: #1a2515;400- color: #f7f0d8;401377 }402378 403-.shikaku-home__feature-card {404- display: grid;405- gap: 0.35rem;406- place-items: start;407- padding: 0.9rem;408- border: 2px solid #cad8a0;409- background: #24341d;410- box-shadow: 4px 4px 0 #0b1209;411-}412-413-@media (min-width: 760px) {414- .shikaku-home__hero {415- grid-template-columns: minmax(0, 1.02fr) minmax(18rem, 0.98fr);416- align-items: stretch;417- padding: 1.35rem;379+.home__foot {380+ display: flex;381+ align-items: center;382+ gap: 0.5rem;383+ color: #6f855a;384+}385+386+/* landscape / wide: scene left, copy right */387+@media (min-aspect-ratio: 5/4) {388+ .home__frame {389+ width: min(100%, 60rem);390+ grid-template-areas: "scene copy";391+ grid-template-columns: minmax(0, 1.1fr) minmax(16rem, 0.9fr);392+ grid-template-rows: minmax(0, 1fr);393+ align-items: center;394+ gap: 2rem;418395 }419-420- .shikaku-home__actions,421- .shikaku-home__feature-strip {422- grid-template-columns: repeat(2, minmax(0, 1fr));396+ .home__scene {397+ place-items: center;423398 }424-425- .shikaku-home__feature-strip {426- grid-template-columns: repeat(3, minmax(0, 1fr));399+ .home__copy {400+ justify-items: start;401+ text-align: left;402+ }403+ .home__stats,404+ .home__actions {405+ justify-content: flex-start;427406 }428407 }429408 430-.shikaku-play-shell {409+/* =====================================================================410+ PLAY411+ ===================================================================== */412+.play {431413 position: fixed;432414 inset: 0;433415 height: 100svh;434416 overflow: hidden;435- background: linear-gradient(180deg, #0f180d 0%, #122411 44%, #0b130b 100%);436- color: #f7f0d8;437- font-family:438- ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",439- "Courier New", monospace;417+ background: radial-gradient(circle at 50% 0%, #1a3016 0, transparent 60%),418+ var(--moss-0);419+ color: var(--parch);440420 }441421 442-.shikaku-play-frame {422+.play__frame {443423 height: 100%;444- width: min(100%, 32rem);424+ width: min(100%, 34rem);445425 margin: 0 auto;446426 display: grid;447- grid-template-rows: auto minmax(0, 1fr);427+ grid-template-areas:428+ "header"429+ "hud"430+ "board"431+ "tray";432+ grid-template-rows: auto auto minmax(0, 1fr) auto;448433 gap: 0.6rem;449- padding: max(0.75rem, env(safe-area-inset-top))450- max(0.75rem, env(safe-area-inset-right))451- max(0.75rem, env(safe-area-inset-bottom))452- max(0.75rem, env(safe-area-inset-left));434+ padding: max(0.6rem, env(safe-area-inset-top))435+ max(0.6rem, env(safe-area-inset-right))436+ max(0.6rem, env(safe-area-inset-bottom))437+ max(0.6rem, env(safe-area-inset-left));453438 }454439 455-.shikaku-play-header,456-.shikaku-game-status,457-.shikaku-game-board-wrap,458-.shikaku-clue-token {459- border: 2px solid #0b1209;460- box-shadow: 4px 4px 0 #0b1209;461-}462-463-.shikaku-play-header {440+.play__header {441+ grid-area: header;464442 display: grid;465- gap: 0.25rem;466- padding: 0.65rem 0.75rem;467- background: #f4ebcf;468- color: #11160d;469-}470-471-.shikaku-play-header__row {472- display: grid;473- gap: 0.4rem;474- justify-items: center;475-}476-477-.shikaku-play-day-nav {478- width: 100%;479- display: grid;480- grid-template-columns: 2.4rem 1fr 2.4rem;443+ grid-template-columns: auto auto minmax(0, 1fr) auto;481444 align-items: center;482445 gap: 0.45rem;483- text-align: center;484-}485-486-.shikaku-play-day-nav--with-home {487- grid-template-columns: minmax(3.75rem, auto) 2.4rem 1fr 2.4rem;446+ padding: 0.45rem 0.55rem;488447 }489448 490-.shikaku-play-nav {449+.play__home {491450 display: inline-flex;492451 align-items: center;493- justify-content: center;494- width: 100%;495- min-height: 2.35rem;496- padding: 0.35rem 0.6rem;497- border: 2px solid #0b1209;498- background: #d4deb0;499- color: #11160d;452+ gap: 0.35rem;453+ color: var(--parch);500454 text-decoration: none;501- font-size: 0.82rem;502- font-weight: 900;503- white-space: nowrap;504- box-shadow: 3px 3px 0 #0b1209;505-}506-507-.shikaku-play-nav--home-text {508- border: 0;509- background: transparent;510- box-shadow: none;511- text-decoration: underline;512- text-underline-offset: 0.16em;513- justify-content: flex-start;514- padding-inline: 0.2rem 0.35rem;515-}516-517-.shikaku-play-nav--icon {518- padding: 0;519- font-size: 1rem;520- line-height: 1;521455 }522456 523-.shikaku-play-nav.is-disabled,524-.shikaku-game-clear[disabled] {525- opacity: 0.45;526- pointer-events: none;527-}528-529-.shikaku-play-kicker {530- font-size: 0.65rem;531- color: #415338;532- letter-spacing: 0.08em;533- text-transform: uppercase;534-}535-536-.shikaku-play-title {537- font-size: clamp(1.35rem, 4vw, 1.75rem);538- line-height: 0.95;539- color: #f8f3de;540- text-shadow:541- 1px 0 0 #0b1209,542- -1px 0 0 #0b1209,543- 0 1px 0 #0b1209,544- 0 -1px 0 #0b1209,545- 3px 3px 0 #3e5f33;457+.play__day {458+ display: grid;459+ justify-items: center;460+ min-width: 0;461+ line-height: 1.05;462+}463+.play__date {464+ font-family: var(--font-pixel);465+ font-weight: 400;466+ font-size: clamp(1rem, 4vw, 1.25rem);467+ color: var(--gold);468+ text-shadow: 2px 2px 0 var(--ink);469+ white-space: nowrap;546470 }547471 548-.shikaku-play-hint {549- font-size: 0.76rem;550- color: #415338;551- text-transform: uppercase;472+.play__hint {473+ grid-column: 1 / -1;474+ margin-top: 0.2rem;475+ font-size: 0.78rem;476+ color: var(--text-dim);477+ text-align: center;478+ white-space: nowrap;479+ overflow: hidden;480+ text-overflow: ellipsis;552481 }553482 554-.shikaku-game-shell {555- min-height: 0;556- height: 100%;557- display: grid;558- grid-template-rows: auto minmax(0, 1fr) auto;559- gap: 0.5rem;560- overflow: hidden;483+.game {484+ display: contents;561485 }562486 563-.shikaku-game-status {487+.hud {488+ grid-area: hud;564489 display: flex;490+ flex-wrap: wrap;565491 align-items: center;566492 justify-content: space-between;567- gap: 0.55rem;568- padding: 0.55rem 0.65rem;569- background: #efe5c2;570- color: #11160d;493+ gap: 0.4rem 0.55rem;494+ padding: 0.45rem 0.6rem;495+ font-family: var(--font-pixel);496+ font-size: 0.9rem;571497 }572-573-.shikaku-game-status--solved {574- background: #d9ebc5;498+.hud--solved {499+ background: var(--moss-2);575500 }576-577-.shikaku-game-status__meta {578- display: grid;579- gap: 0.05rem;580- min-width: 0;501+.hud__rooms {502+ display: flex;503+ flex-wrap: wrap;504+ gap: 3px;505+ max-width: 50%;581506 }582-583-.shikaku-game-status__meta strong {584- font-size: 0.9rem;585- text-transform: uppercase;507+.hud__rooms i {508+ width: 9px;509+ height: 9px;510+ background: var(--moss-3);511+ border: 1px solid var(--ink);512+ transition: background 120ms var(--step);586513 }587-588-.shikaku-game-status__meta span {589- color: #415338;590- font-size: 0.73rem;591- line-height: 1.2;592- white-space: nowrap;593- overflow: hidden;594- text-overflow: ellipsis;595- text-transform: uppercase;514+.hud__rooms i.on {515+ background: var(--gold);596516 }597-598-.shikaku-game-actions {599- display: flex;600- align-items: center;601- gap: 0.45rem;602- flex-shrink: 0;517+.hud__count {518+ color: var(--parch);603519 }604-605-.shikaku-game-clear {606- border: 2px solid #0b1209;607- background: #d4deb0;608- padding: 0.55rem 0.7rem;609- font-size: 0.74rem;610- font-weight: 900;611- text-transform: uppercase;520+.hud__count small {521+ color: var(--text-dim);522+ font-size: 0.75em;523+}524+.hud__actions {525+ display: flex;526+ gap: 0.4rem;612527 flex-shrink: 0;613- box-shadow: 3px 3px 0 #0b1209;614528 }615-616-.shikaku-game-clear--link {617- display: inline-flex;618- align-items: center;619- color: inherit;620- text-decoration: none;529+.hud__msg {530+ flex: 1 1 100%;531+ order: 10;532+ min-width: 0;533+ font-family: var(--font-body);534+ font-size: 0.78rem;535+ color: var(--text-soft);536+ white-space: nowrap;537+ overflow: hidden;538+ text-overflow: ellipsis;621539 }622540 623-.shikaku-game-board-wrap {541+/* board sizes itself from its container in every orientation */542+.board-wrap {543+ grid-area: board;624544 min-height: 0;545+ min-width: 0;546+ container-type: size;625547 display: grid;626548 place-items: center;627- padding: 0.25rem;628- background: #f4ebcf;629- overflow: hidden;630549 }631550 632-.shikaku-board--fixed {633- width: min(100%, calc(100dvh - 21rem));634- max-width: 100%;551+.board {552+ width: min(100cqw, 100cqh);635553 aspect-ratio: 1 / 1;636554 display: grid;637- border: 2px solid #0b1209;638- background: #fff8e4;555+ padding: 3px;556+ border: 2px solid var(--ink);557+ background: var(--parch-2);558+ background-image: radial-gradient(#c9bb8c 1px, transparent 1.3px);559+ background-size: 8px 8px;560+ box-shadow: inset 3px 3px 0 #6d6247, inset -3px -3px 0 var(--parch-hi);561+ touch-action: none;562+ user-select: none;563+ -webkit-user-select: none;564+ overscroll-behavior: none;565+}566+.board.is-shake {567+ animation: shake 220ms steps(4, end);568+}569+@keyframes shake {570+ 25% {571+ transform: translateX(-3px);572+ }573+ 50% {574+ transform: translateX(3px);575+ }576+ 75% {577+ transform: translateX(-2px);578+ }639579 }640580 641-.shikaku-board__cell {581+.cell {582+ position: relative;642583 width: 100%;643584 aspect-ratio: 1 / 1;644- border: 1px solid #0b1209;645- position: relative;646- font-size: clamp(0.72rem, 2.7vw, 0.96rem);647- font-weight: 900;648- color: #11160d;649-}650-651-.shikaku-clue-strip {652585 display: grid;653- grid-template-columns: repeat(6, minmax(0, 1fr));654- gap: 0.3rem;655-}656-657-.shikaku-clue-token {658- display: inline-flex;659- align-items: center;660- justify-content: center;661- gap: 0.22rem;662- min-height: 2.15rem;663- padding: 0.2rem 0.1rem;664- background: #efe5c2;665- color: #11160d;666-}667-668-.shikaku-clue-token--dim {669- color: #6f7763;670-}671-672-.shikaku-clue-token__symbol {673- color: currentColor;674- font-size: 0.72rem;675- line-height: 1;676-}677-678-.shikaku-clue-token__value {679- font-weight: 900;680- font-size: 0.8rem;586+ place-items: center;587+ padding: 0;588+ border: 0;589+ background: transparent;590+ color: var(--ink);591+ font-family: var(--font-pixel);592+ font-size: clamp(0.7rem, min(3.6cqw, 3.6cqh), 1.15rem);681593 line-height: 1;594+ touch-action: none;595+ -webkit-tap-highlight-color: transparent;682596 }683-684-.shikaku-clue-token.is-placed {685- background: color-mix(in srgb, var(--token-color) 34%, #efe5c2);597+.cell.is-filled {598+ background: var(--cell-color);599+ box-shadow: inset 2px 2px 0 rgba(255, 255, 255, 0.35),600+ inset -2px -2px 0 rgba(0, 0, 0, 0.28);601+}602+.cell.is-preview {603+ background: rgba(242, 203, 85, 0.35);604+ box-shadow: inset 0 0 0 2px var(--gold-lo);605+}606+.cell.is-active {607+ box-shadow: inset 0 0 0 3px var(--gold),608+ inset 3px 3px 0 rgba(255, 255, 255, 0.35),609+ inset -3px -3px 0 rgba(0, 0, 0, 0.28);610+}611+.cell__num {612+ padding: 0.05em 0.3em;613+ background: var(--ink);614+ color: var(--parch);615+ box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35);616+}617+.cell.is-active .cell__num {618+ background: var(--gold);619+ color: var(--ink);620+}621+.cell.is-new {622+ animation: pop 140ms steps(3, end);623+}624+@keyframes pop {625+ 0% {626+ transform: scale(0.85);627+ filter: brightness(1.6);628+ }629+ 100% {630+ transform: none;631+ filter: none;632+ }686633 }687-688-.shikaku-clue-token.is-active {689- outline: 2px solid #f2cb55;690- outline-offset: -2px;634+.cell.is-lit {635+ animation: lit 900ms steps(3, end) both;691636 }692-693-@media (max-width: 420px) {694- .shikaku-play-frame {695- padding-inline: 0.55rem;637+@keyframes lit {638+ 0% {639+ background: var(--cell-color);696640 }697-698- .shikaku-board--fixed {699- width: min(100%, calc(100dvh - 20.4rem));641+ 30% {642+ background: var(--gold);700643 }701-702- .shikaku-clue-strip {703- gap: 0.24rem;704- }705-706- .shikaku-clue-token {707- min-height: 2rem;708- padding: 0.15rem 0.05rem;644+ 100% {645+ background: var(--cell-color);709646 }710647 }711648 712-.shikaku-app-shell {713- min-height: 100%;714- padding: max(1rem, env(safe-area-inset-top))715- max(1rem, env(safe-area-inset-right))716- max(1.5rem, env(safe-area-inset-bottom))717- max(1rem, env(safe-area-inset-left));718-}719-720-.shikaku-board {721- touch-action: none;722- user-select: none;723- -webkit-user-select: none;724- overscroll-behavior: none;725-}726-727-.shikaku-board button {728- touch-action: none;729- -webkit-tap-highlight-color: transparent;649+/* tray: clue tokens, or the result card once solved */650+.tray {651+ grid-area: tray;652+ min-width: 0;730653 }731654 732-.transition-colors {733- transition-property: background-color, border-color, color, fill, stroke;734- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);735- transition-duration: 150ms;736-}737-.my-6 {738- margin-bottom: 1.5rem;739- margin-top: 1.5rem;740-}741-.text-4xl {742- font-size: 2.25rem;743- line-height: 2.5rem;744-}745-.mx-2 {746- margin-left: 0.5rem;747- margin-right: 0.5rem;655+.tokens {656+ display: grid;657+ grid-template-columns: repeat(auto-fill, minmax(2.4rem, 1fr));658+ gap: 0.3rem;748659 }749-.my-4 {750- margin-bottom: 1rem;751- margin-top: 1rem;660+.token {661+ display: inline-flex;662+ align-items: center;663+ justify-content: center;664+ gap: 0.2rem;665+ min-height: 2.15rem;666+ padding: 0.15rem 0.1rem;667+ border: 2px solid var(--ink);668+ background: var(--parch-2);669+ color: var(--ink);670+ font-family: var(--font-pixel);671+ font-size: 0.95rem;672+ line-height: 1;673+ box-shadow: var(--bevel-parch);752674 }753-.mx-auto {754- margin-left: auto;755- margin-right: auto;675+.token__sym {676+ font-size: 0.65em;677+ opacity: 0.7;756678 }757-.px-4 {758- padding-left: 1rem;759- padding-right: 1rem;679+.token.is-placed {680+ background: var(--token-color);681+ color: var(--parch);682+ text-shadow: 1px 1px 0 var(--ink);683+ box-shadow: inset 2px 2px 0 rgba(255, 255, 255, 0.35),684+ inset -2px -2px 0 rgba(0, 0, 0, 0.35);760685 }761-.py-8 {762- padding-bottom: 2rem;763- padding-top: 2rem;686+.token.is-placed .token__sym {687+ opacity: 0.85;764688 }765-.bg-\[\#86efac\] {766- background-color: #86efac;689+.token.is-active {690+ outline: 3px solid var(--gold);691+ outline-offset: -3px;767692 }768-.text-3xl {769- font-size: 1.875rem;770- line-height: 2.25rem;693+694+.result {695+ display: grid;696+ gap: 0.6rem;697+ padding: 0.7rem;698+ text-align: center;699+ justify-items: center;700+ animation: rise 220ms steps(4, end);771701 }772-.py-6 {773- padding-bottom: 1.5rem;774- padding-top: 1.5rem;702+@keyframes rise {703+ from {704+ transform: translateY(8px);705+ opacity: 0;706+ }775707 }776-.px-2 {777- padding-left: 0.5rem;778- padding-right: 0.5rem;708+.result__rank {709+ font-family: var(--font-title);710+ font-weight: 400;711+ font-size: 1.1rem;712+ color: var(--gold);713+ text-shadow: 2px 2px 0 var(--ink);779714 }780-.py-1 {781- padding-bottom: 0.25rem;782- padding-top: 0.25rem;715+.result__stats {716+ width: 100%;717+ display: grid;718+ grid-template-columns: repeat(3, minmax(0, 1fr));719+ gap: 0.4rem;783720 }784-.border-gray-500 {785- border-color: #6b7280;721+.result__stats div {722+ border: 2px solid var(--ink);723+ background: var(--moss-2);724+ padding: 0.4rem 0.2rem;725+ box-shadow: inset 2px 2px 0 #3a5c34, inset -2px -2px 0 var(--moss-0);786726 }787-.bg-white {788- background-color: #fff;727+.result__stats b {728+ display: block;729+ font-family: var(--font-pixel);730+ font-weight: 400;731+ font-size: 1.25rem;732+ color: var(--parch);733+ font-variant-numeric: tabular-nums;789734 }790-.flex {735+.result__actions {791736 display: flex;792-}793-.gap-8 {794- grid-gap: 2rem;795- gap: 2rem;796-}797-.font-bold {798- font-weight: 700;799-}800-.max-w-screen-md {801- max-width: 768px;802-}803-.flex-col {804- flex-direction: column;805-}806-.items-center {807- align-items: center;808-}809-.justify-center {737+ flex-wrap: wrap;810738 justify-content: center;739+ gap: 0.5rem;811740 }812-.border-2 {813- border-width: 2px;814-}815-.rounded-sm {816- border-radius: 0.25rem;817-}818-.hover\:bg-gray-200:hover {819- background-color: #e5e7eb;741+742+/* landscape / square-ish: board left, everything else in a right rail */743+@media (min-aspect-ratio: 5/4) {744+ .play__frame {745+ width: 100%;746+ max-width: 72rem;747+ grid-template-areas:748+ "board header"749+ "board hud"750+ "board tray"751+ "board .";752+ grid-template-columns: minmax(0, 1fr) minmax(15rem, 22rem);753+ grid-template-rows: auto auto auto minmax(0, 1fr);754+ column-gap: 1rem;755+ }756+ .hud__rooms {757+ max-width: 100%;758+ }759+ .hud__msg {760+ white-space: normal;761+ }762+ .tokens {763+ grid-template-columns: repeat(auto-fill, minmax(2.6rem, 1fr));764+ }820765 }821-.tabular-nums {822- font-variant-numeric: tabular-nums;766+767+/* short landscape phones: tighten */768+@media (min-aspect-ratio: 5/4) and (max-height: 480px) {769+ .play__frame {770+ gap: 0.4rem;771+ padding-block: max(0.4rem, env(safe-area-inset-top))772+ max(0.4rem, env(safe-area-inset-bottom));773+ }774+ .play__hint {775+ display: none;776+ }823777 }824-.min-h-screen {825- min-height: 100vh;778+779+.play__notfound {780+ padding: 1rem;826781 }827782 828-.fresh-gradient {829- background-color: rgb(134, 239, 172);830- background-image: linear-gradient(831- to right bottom,832- rgb(219, 234, 254),833- rgb(187, 247, 208),834- rgb(254, 249, 195)835- );783+@media (prefers-reduced-motion: reduce) {784+ *,785+ *::before,786+ *::after {787+ animation: none ;788+ transition: none ;789+ }836790 }@@ -0,0 +1,28 @@
1+interface DeltaMarkProps {2+ size?: number;3+ ring?: "white" | "black";4+ variant?: "static" | "flicker";5+ class?: string;6+}7+8+/** Studio mark: delta inside a ring. Ring is always pure black or white. */9+export default function DeltaMark(10+ { size = 24, ring = "white", variant = "static", class: className }:11+ DeltaMarkProps,12+) {13+ return (14+ <svg15+ class={`delta-mark delta-mark--${ring}${16+ variant === "flicker" ? " delta-mark--flicker" : ""17+ }${className ? ` ${className}` : ""}`}18+ viewBox="0 0 24 24"19+ width={size}20+ height={size}21+ aria-hidden="true"22+ focusable="false"23+ >24+ <path class="delta-mark__delta" d="M16,15,12,7,8,15Zm-1,0L11.5,8" />25+ <circle class="delta-mark__ring" cx="12" cy="12" r="9" />26+ </svg>27+ );28+}@@ -0,0 +1,74 @@
1+/** 64×30 pixel night scene: moon, keep with lit windows, treeline. */2+export default function KeepScene() {3+ return (4+ <svg5+ class="keep-scene"6+ viewBox="0 0 64 30"7+ shape-rendering="crispEdges"8+ aria-hidden="true"9+ focusable="false"10+ >11+ <rect width="64" height="30" fill="#10203a" />12+ <g fill="#fff" class="keep-scene__stars">13+ <rect x="6" y="3" width="1" height="1" />14+ <rect x="20" y="6" width="1" height="1" />15+ <rect x="41" y="2" width="1" height="1" />16+ <rect x="55" y="7" width="1" height="1" />17+ <rect x="33" y="9" width="1" height="1" />18+ <rect x="12" y="10" width="1" height="1" />19+ </g>20+ <rect x="50" y="3" width="5" height="5" fill="#f6efc9" />21+ <rect x="52" y="4" width="2" height="2" fill="#e0d6a6" />22+ <rect x="0" y="20" width="64" height="10" fill="#1f3a1c" />23+ <rect x="0" y="18" width="64" height="2" fill="#2f5527" />24+ <g fill="#0f180d">25+ <rect x="2" y="15" width="3" height="5" />26+ <rect x="58" y="14" width="4" height="6" />27+ <rect x="10" y="16" width="2" height="4" />28+ <rect x="52" y="16" width="2" height="4" />29+ </g>30+ <rect x="21" y="12" width="22" height="8" fill="#5b5b64" />31+ <rect x="21" y="12" width="22" height="1" fill="#7a7a81" />32+ <g fill="#5b5b64">33+ <rect x="21" y="11" width="2" height="1" />34+ <rect x="25" y="11" width="2" height="1" />35+ <rect x="29" y="11" width="2" height="1" />36+ <rect x="33" y="11" width="2" height="1" />37+ <rect x="37" y="11" width="2" height="1" />38+ <rect x="41" y="11" width="2" height="1" />39+ </g>40+ <g fill="#6a6a72">41+ <rect x="18" y="8" width="5" height="12" />42+ <rect x="18" y="7" width="1" height="1" />43+ <rect x="20" y="7" width="1" height="1" />44+ <rect x="22" y="7" width="1" height="1" />45+ <rect x="41" y="8" width="5" height="12" />46+ <rect x="41" y="7" width="1" height="1" />47+ <rect x="43" y="7" width="1" height="1" />48+ <rect x="45" y="7" width="1" height="1" />49+ </g>50+ <g fill="#7a7a81">51+ <rect x="29" y="4" width="6" height="16" />52+ <rect x="29" y="3" width="1" height="1" />53+ <rect x="31" y="3" width="2" height="1" />54+ <rect x="34" y="3" width="1" height="1" />55+ </g>56+ <g fill="#f2cb55" class="keep-scene__windows">57+ <rect x="31" y="7" width="2" height="2" />58+ <rect x="19" y="11" width="1" height="2" />59+ <rect x="43" y="11" width="1" height="2" />60+ </g>61+ <rect x="30" y="16" width="4" height="4" fill="#0a120a" />62+ <rect x="31" y="15" width="2" height="1" fill="#0a120a" />63+ <rect x="31" y="0" width="1" height="4" fill="#3e2a1a" />64+ <rect65+ x="32"66+ y="0"67+ width="3"68+ height="2"69+ fill="#b8483d"70+ class="keep-scene__flag"71+ />72+ </svg>73+ );74+}@@ -11,6 +11,7 @@ import {
1111 getSelectedStackIndex,1212 getVisibleStackRange,1313 } from "@/lib/storage/local_progress.ts";14+import DeltaMark from "../components/DeltaMark.tsx";1415 import ShikakuGame from "./ShikakuGame.tsx";1516 1617 interface DailyDateControllerProps {@@ -42,15 +43,6 @@ export default function DailyDateController(
4243 visibleMaxStack: nextVisibleMax,4344 });4445 45- console.debug("[rect.run] daily-init", {46- date,47- today: nextToday,48- selectedStackIndex: nextSelected,49- playableStackIndex: nextPlayable,50- visibleMaxStack: nextVisibleMax,51- initialState,52- });53-5446 today.value = nextToday;5547 playableStack.value = initialState.playableStack;5648 visibleMaxStack.value = initialState.visibleMaxStack;@@ -99,10 +91,10 @@ export default function DailyDateController(
9991 10092 if (!puzzle) {10193 return (102- <main class="shikaku-play-shell">103- <div class="shikaku-play-frame">104- <header class="shikaku-play-header">105- <p class="shikaku-play-hint">Puzzle not found.</p>94+ <main class="play">95+ <div class="play__frame">96+ <header class="play__header panel play__notfound">97+ <p class="px">Puzzle not found.</p>10698 </header>10799 </div>108100 </main>@@ -145,65 +137,49 @@ export default function DailyDateController(
145137 const canGoUp = selectedStack.value < highestStack;146138 147139 return (148- <main class="shikaku-play-shell">149- <div class="shikaku-play-frame">150- <header class="shikaku-play-header">151- <div class="shikaku-play-header__row">152- <div class="shikaku-play-day-nav shikaku-play-day-nav--with-home">153- <a154- class="shikaku-play-nav shikaku-play-nav--icon"155- href="/"156- aria-label="Back home"157- >158- ⌂159- </a>160- {canGoDown161- ? (162- <button163- type="button"164- class="shikaku-play-nav shikaku-play-nav--icon"165- aria-label="Previous stack"166- onClick={() => {167- selectedStack.value -= 1;168- replayStack.value = null;169- }}170- >171- ‹172- </button>173- )174- : (175- <span class="shikaku-play-nav shikaku-play-nav--icon is-disabled">176- ‹177- </span>178- )}179- <div>180- <p class="shikaku-play-kicker">181- Daily stack · {selectedStack.value}182- </p>183- <h1 class="shikaku-play-title">{date}</h1>184- </div>185- {canGoUp186- ? (187- <button188- type="button"189- class="shikaku-play-nav shikaku-play-nav--icon"190- aria-label="Next stack"191- onClick={() => {192- selectedStack.value += 1;193- replayStack.value = null;194- }}195- >196- ›197- </button>198- )199- : (200- <span class="shikaku-play-nav shikaku-play-nav--icon is-disabled">201- ›202- </span>203- )}204- </div>140+ <main class="play">141+ <div class="play__frame">142+ <header class="play__header panel">143+ <a144+ class="play__home btn btn--ghost btn--sm btn--icon"145+ href="/"146+ aria-label="Back home"147+ >148+ <DeltaMark size={20} ring="black" />149+ </a>150+ <button151+ type="button"152+ class={`btn btn--ghost btn--sm btn--icon${153+ canGoDown ? "" : " is-disabled"154+ }`}155+ aria-label="Previous floor"156+ disabled={!canGoDown}157+ onClick={() => {158+ selectedStack.value -= 1;159+ replayStack.value = null;160+ }}161+ >162+ ‹163+ </button>164+ <div class="play__day">165+ <span class="label">Floor {selectedStack.value + 1}</span>166+ <h1 class="play__date">{formatDate(date)}</h1>205167 </div>206- <p class="shikaku-play-hint">{dayState.message}</p>168+ <button169+ type="button"170+ class={`btn btn--ghost btn--sm btn--icon${171+ canGoUp ? "" : " is-disabled"172+ }`}173+ aria-label="Next floor"174+ disabled={!canGoUp}175+ onClick={() => {176+ selectedStack.value += 1;177+ replayStack.value = null;178+ }}179+ >180+ ›181+ </button>182+ <p class="play__hint">{dayState.message}</p>207183 </header>208184 209185 <ShikakuGame@@ -215,8 +191,8 @@ export default function DailyDateController(
215191 initialMessage={dayState.message}216192 solvedMessage={date === today.value &&217193 selectedStack.value === playableStack.value218- ? "Solved. Stack above unlocked."219- : "Solved!"}194+ ? "Floor cleared. Next floor unlocked."195+ : "Floor cleared."}220196 onReset={dayState.mode !== "playable" ? handleReplayReset : undefined}221197 onSolved={handleSolved}222198 />@@ -224,3 +200,12 @@ export default function DailyDateController(
224200 </main>225201 );226202 }203+204+function formatDate(date: string): string {205+ const [year, month, day] = date.split("-").map(Number);206+ return new Intl.DateTimeFormat("en-US", {207+ month: "short",208+ day: "numeric",209+ year: "numeric",210+ }).format(new Date(year, month - 1, day)).toUpperCase();211+}@@ -11,20 +11,13 @@ export default function DailyStackLink({ fallbackDate }: DailyStackLinkProps) {
1111 const [href, setHref] = useState(buildDailyPath(fallbackDate));1212 1313 useEffect(() => {14- const localDate = dateToSlug(new Date());15- const nextHref = buildDailyPath(localDate);16- console.debug("[rect.run] home-play-link", {17- fallbackDate,18- localDate,19- nextHref,20- });21- setHref(nextHref);14+ setHref(buildDailyPath(dateToSlug(new Date())));2215 }, [fallbackDate]);2316 2417 return (25- <a class="shikaku-home__action shikaku-home__action--gold" href={href}>26- <span class="shikaku-home__action-kicker">/ START</span>27- <strong>Play</strong>18+ <a class="btn" href={href}>19+ <span class="btn__blink" aria-hidden="true">▶</span>20+ PLAY TODAY2821 </a>2922 );3023 }@@ -1,52 +0,0 @@
1-import { useEffect, useState } from "preact/hooks";2-3-import { dateToSlug } from "@/lib/date/daily.ts";4-import { getInitialStackIndex } from "@/lib/shikaku/day_stack_state.ts";5-import {6- getPlayableStackIndex,7- getSelectedStackIndex,8-} from "@/lib/storage/local_progress.ts";9-10-interface HomeDateStackEyebrowProps {11- fallbackDate: string;12-}13-14-export default function HomeDateStackEyebrow(15- { fallbackDate }: HomeDateStackEyebrowProps,16-) {17- const [label, setLabel] = useState(buildLabel(fallbackDate, 0));18-19- useEffect(() => {20- const localDate = dateToSlug(new Date());21- const selectedStackIndex = getSelectedStackIndex(22- globalThis.localStorage,23- localDate,24- );25- const playableStackIndex = getPlayableStackIndex(26- globalThis.localStorage,27- localDate,28- );29- const stackIndex = getInitialStackIndex({30- date: localDate,31- today: localDate,32- selectedStackIndex,33- playableStackIndex,34- });35-36- setLabel(buildLabel(localDate, stackIndex));37- }, []);38-39- return <div class="shikaku-home__eyebrow">{label}</div>;40-}41-42-function buildLabel(date: string, stackIndex: number): string {43- const [year, month, day] = date.split("-").map(Number);44- const value = new Date(year, month - 1, day);45- const formatted = new Intl.DateTimeFormat("en-US", {46- month: "short",47- day: "numeric",48- year: "numeric",49- }).format(value);50-51- return `${formatted} - Stack ${stackIndex + 1}`;52-}@@ -0,0 +1,60 @@
1+import { useEffect, useState } from "preact/hooks";2+3+import { dateToSlug } from "@/lib/date/daily.ts";4+import { DAILY_STREAK_START_DATE } from "@/lib/shikaku/catalog.ts";5+import { getInitialStackIndex } from "@/lib/shikaku/day_stack_state.ts";6+import {7+ getDailyStreak,8+ getPlayableStackIndex,9+ getSelectedStackIndex,10+} from "@/lib/storage/local_progress.ts";11+12+interface HomeStatsProps {13+ fallbackDate: string;14+}15+16+export default function HomeStats({ fallbackDate }: HomeStatsProps) {17+ const [stats, setStats] = useState({18+ day: dayNumber(fallbackDate),19+ streak: 0,20+ floor: 1,21+ });22+23+ useEffect(() => {24+ const localDate = dateToSlug(new Date());25+ const storage = globalThis.localStorage;26+ const stackIndex = getInitialStackIndex({27+ date: localDate,28+ today: localDate,29+ selectedStackIndex: getSelectedStackIndex(storage, localDate),30+ playableStackIndex: getPlayableStackIndex(storage, localDate),31+ });32+ setStats({33+ day: dayNumber(localDate),34+ streak: getDailyStreak(storage, localDate),35+ floor: stackIndex + 1,36+ });37+ }, []);38+39+ return (40+ <div class="home__stats" aria-label="Your progress">41+ <span>42+ DAY <b>{stats.day}</b>43+ </span>44+ <span>45+ STREAK <b>{stats.streak}</b>46+ </span>47+ <span>48+ FLOOR <b>{stats.floor}</b>49+ </span>50+ </div>51+ );52+}53+54+function dayNumber(date: string): number {55+ const [y1, m1, d1] = DAILY_STREAK_START_DATE.split("-").map(Number);56+ const [y2, m2, d2] = date.split("-").map(Number);57+ const start = Date.UTC(y1, m1 - 1, d1);58+ const end = Date.UTC(y2, m2 - 1, d2);59+ return Math.max(1, Math.round((end - start) / 86_400_000) + 1);60+}@@ -5,13 +5,15 @@ import { getAchievedStackIndex } from "@/lib/storage/local_progress.ts";
55 66 interface ShareDailyStackButtonProps {77 date: string;8+ class?: string;89 }910 1011 export default function ShareDailyStackButton(11- { date }: ShareDailyStackButtonProps,12+ { date, class: className = "btn btn--ghost" }: ShareDailyStackButtonProps,1213 ) {1314 const [stackIndex, setStackIndex] = useState(0);1415 const [origin, setOrigin] = useState("");16+ const [copied, setCopied] = useState(false);1517 1618 useEffect(() => {1719 setOrigin(globalThis.location.origin);@@ -29,7 +31,7 @@ export default function ShareDailyStackButton(
2931 try {3032 if (navigator.share) {3133 await navigator.share({32- title: "Daily stack",34+ title: "rect.run",3335 text: shareText,3436 url: shareUrl,3537 });@@ -37,19 +39,16 @@ export default function ShareDailyStackButton(
3739 }3840 3941 await navigator.clipboard.writeText(shareUrl);42+ setCopied(true);43+ setTimeout(() => setCopied(false), 1500);4044 } catch {4145 // ignore user-cancelled share errors and clipboard failures4246 }4347 }4448 4549 return (46- <button47- class="shikaku-home__action shikaku-home__action--ghost"48- type="button"49- onClick={handleShare}50- >51- <span class="shikaku-home__action-kicker">/ SHARE</span>52- <strong>Share daily stack</strong>50+ <button class={className} type="button" onClick={handleShare}>51+ {copied ? "COPIED" : "SHARE"}5352 </button>5453 );5554 }@@ -10,21 +10,25 @@ import {
1010 } from "@/lib/shikaku/engine.ts";1111 import { getGridPointFromBoardPosition } from "@/lib/shikaku/interaction.ts";1212 import { getClueSymbol } from "@/lib/shikaku/clue_symbol.ts";13-import { getClueTone } from "@/lib/shikaku/clue_tone.ts";13+import { getBoardColor } from "@/lib/shikaku/palette.ts";14+import { formatElapsed, getSolveRank } from "@/lib/shikaku/rank.ts";1415 import {1516 canRemovePlacedRectangle,1617 getStatusTone,1718 } from "@/lib/shikaku/play_state.ts";1819 import {20+ getDailyStreak,1921 loadPuzzleProgress,2022 savePuzzleProgress,2123 } from "@/lib/storage/local_progress.ts";24+import { isSfxEnabled, playSfx, setSfxEnabled } from "@/lib/audio/sfx.ts";2225 import type {2326 GridPoint,2427 PlacedRectangle,2528 PuzzleProgress,2629 ShikakuPuzzle,2730 } from "@/lib/shikaku/types.ts";31+import ShareDailyStackButton from "./ShareDailyStreakButton.tsx";2832 2933 interface ShikakuGameProps {3034 puzzle: ShikakuPuzzle;@@ -37,16 +41,11 @@ interface ShikakuGameProps {
3741 onSolved?: () => void;3842 }3943 40-const COLORS = [41- "#fde68a",42- "#bfdbfe",43- "#bbf7d0",44- "#fbcfe8",45- "#ddd6fe",46- "#fecaca",47- "#fdba74",48- "#a7f3d0",49-];44+const RANK_LABEL = {45+ squire: "Floor cleared · Squire",46+ knight: "Floor cleared · Knight",47+ paladin: "Floor cleared · Paladin",48+} as const;5049 5150 export default function ShikakuGame(props: ShikakuGameProps) {5251 const {@@ -60,13 +59,16 @@ export default function ShikakuGame(props: ShikakuGameProps) {
6059 onSolved,6160 } = props;6261 const resolvedInitialMessage = initialMessage ??63- (readOnly64- ? lockedMessage65- : "Drag to size a rectangle. Release when it encloses one clue.");62+ (readOnly ? lockedMessage : "Drag a box around one number.");6663 const resolvedResetEnabled = resetEnabled ?? !readOnly;6764 6865 const boardRef = useRef<HTMLDivElement>(null);6966 const previousPuzzleIdRef = useRef("");67+ const startedAtRef = useRef<string>("");68+ const resetsRef = useRef(0);69+ const mistakesRef = useRef(0);70+ const elapsedRef = useRef(0);71+7072 const rectangles = useSignal<PlacedRectangle[]>([]);7173 const activeClueId = useSignal<number | null>(null);7274 const dragAnchor = useSignal<GridPoint | null>(null);@@ -75,32 +77,46 @@ export default function ShikakuGame(props: ShikakuGameProps) {
7577 const status = useSignal<"in_progress" | "solved">("in_progress");7678 const message = useSignal(resolvedInitialMessage);7779 const hydrated = useSignal(false);80+ const sfxOn = useSignal(true);81+ const shaking = useSignal(false);82+ const justPlacedClueId = useSignal<number | null>(null);83+ const celebrate = useSignal(false);84+ const streak = useSignal(0);7885 7986 function persistProgress(8087 nextRectangles: PlacedRectangle[],8188 nextStatus: "in_progress" | "solved",8289 ) {8390 const now = new Date().toISOString();91+ if (!startedAtRef.current) startedAtRef.current = now;92+ if (nextStatus === "solved" && elapsedRef.current === 0) {93+ elapsedRef.current = Date.parse(now) -94+ Date.parse(startedAtRef.current);95+ }8496 const progress: PuzzleProgress = {8597 date: puzzle.date,8698 puzzleId: puzzle.id,87- startedAt: now,99+ startedAt: startedAtRef.current,88100 updatedAt: now,89101 status: nextStatus,90- elapsedMs: 0,91- mistakes: 0,102+ elapsedMs: elapsedRef.current,103+ mistakes: mistakesRef.current,92104 selections: nextRectangles.map((rectangle) => ({93105 clueId: rectangle.clueId,94106 cells: getRectangleCells(rectangle),95107 value: rectangle.value,96108 })),97- notes: {},109+ notes: { resets: String(resetsRef.current) },98110 lastViewedAt: now,99111 clientVersion: 1,100112 };101113 savePuzzleProgress(globalThis.localStorage, progress);102114 }103115 116+ useEffect(() => {117+ sfxOn.value = isSfxEnabled(globalThis.localStorage);118+ }, []);119+104120 useEffect(() => {105121 if (!shouldReloadSavedProgress(previousPuzzleIdRef.current, puzzle.id)) {106122 return;@@ -113,6 +129,12 @@ export default function ShikakuGame(props: ShikakuGameProps) {
113129 message.value = saved?.status === "solved"114130 ? solvedMessage115131 : resolvedInitialMessage;132+ startedAtRef.current = saved?.startedAt ?? "";133+ elapsedRef.current = saved?.elapsedMs ?? 0;134+ mistakesRef.current = saved?.mistakes ?? 0;135+ resetsRef.current = Number(saved?.notes.resets ?? 0) || 0;136+ celebrate.value = false;137+ streak.value = getDailyStreak(globalThis.localStorage, puzzle.date);116138 hydrated.value = true;117139 }, [puzzle.id, resolvedInitialMessage, solvedMessage]);118140 @@ -128,13 +150,21 @@ export default function ShikakuGame(props: ShikakuGameProps) {
128150 );129151 return {130152 given,131- color: COLORS[index % COLORS.length],153+ color: getBoardColor(index),132154 placed: Boolean(rectangle),133155 active: activeClueId.value === given.id,134156 };135157 });136158 }, [puzzle.givens, rectangles.value, activeClueId.value]);137159 160+ const colorByClueId = useMemo(() => {161+ const map = new Map<number, string>();162+ puzzle.givens.forEach((given, index) =>163+ map.set(given.id, getBoardColor(index))164+ );165+ return map;166+ }, [puzzle.givens]);167+138168 const previewRectangle = dragAnchor.value && dragCurrent.value139169 ? createRectangleFromCorners(dragAnchor.value, dragCurrent.value)140170 : null;@@ -142,6 +172,15 @@ export default function ShikakuGame(props: ShikakuGameProps) {
142172 previewRectangle ? getRectangleCells(previewRectangle) : [],143173 );144174 175+ function sfx(name: Parameters<typeof playSfx>[0]) {176+ playSfx(name, sfxOn.value);177+ }178+179+ function shake() {180+ shaking.value = true;181+ setTimeout(() => (shaking.value = false), 240);182+ }183+145184 function getContainingRectangle(point: GridPoint) {146185 return rectangles.value.find((rectangle) =>147186 point.row >= rectangle.row &&@@ -171,13 +210,14 @@ export default function ShikakuGame(props: ShikakuGameProps) {
171210 dragAnchor.value = point;172211 dragCurrent.value = point;173212 dragPointerId.value = pointerId;213+ if (!startedAtRef.current) startedAtRef.current = new Date().toISOString();174214 175215 const startingClue = puzzle.givens.find((given) =>176216 given.row === point.row && given.col === point.col177217 );178218 activeClueId.value = startingClue?.id ?? null;179- message.value =180- "Drag to size a rectangle. Release when it encloses one clue.";219+ message.value = "Release when the box holds exactly one number.";220+ sfx("tap");181221 }182222 183223 function updateDrag(point: GridPoint, pointerId: number) {@@ -218,7 +258,10 @@ export default function ShikakuGame(props: ShikakuGameProps) {
218258 clearDrag();219259 220260 if (!validation.ok) {261+ mistakesRef.current += 1;221262 message.value = humanizeValidation(validation.reason);263+ sfx("invalid");264+ shake();222265 return;223266 }224267 @@ -230,16 +273,23 @@ export default function ShikakuGame(props: ShikakuGameProps) {
230273 231274 const nextRectangles = [...existingWithoutActive, nextRectangle];232275 rectangles.value = nextRectangles;276+ justPlacedClueId.value = nextRectangle.clueId;277+ setTimeout(() => (justPlacedClueId.value = null), 200);233278 234279 if (isPuzzleSolved(puzzle, nextRectangles)) {235280 status.value = "solved";236281 persistProgress(nextRectangles, "solved");237282 message.value = solvedMessage;283+ celebrate.value = true;284+ streak.value = getDailyStreak(globalThis.localStorage, puzzle.date);285+ sfx("solve");238286 onSolved?.();239287 return;240288 }241289 242- message.value = "Rectangle placed.";290+ message.value =291+ `${nextRectangles.length} of ${puzzle.givens.length} rooms sealed.`;292+ sfx("place");243293 }244294 245295 function handleCellPointerDown(point: GridPoint, pointerId: number) {@@ -262,7 +312,8 @@ export default function ShikakuGame(props: ShikakuGameProps) {
262312 );263313 if (status.value === "solved") status.value = "in_progress";264314 clearDrag();265- message.value = "Removed region.";315+ message.value = "Room opened.";316+ sfx("remove");266317 return;267318 }268319 @@ -315,29 +366,59 @@ export default function ShikakuGame(props: ShikakuGameProps) {
315366 return;316367 }317368 369+ if (rectangles.value.length > 0) resetsRef.current += 1;318370 rectangles.value = [];319371 clearDrag();320372 status.value = "in_progress";373+ celebrate.value = false;374+ elapsedRef.current = 0;321375 message.value = "Board cleared.";376+ sfx("clear");322377 onReset?.();323378 }324379 380+ function toggleSfx() {381+ sfxOn.value = !sfxOn.value;382+ setSfxEnabled(globalThis.localStorage, sfxOn.value);383+ if (sfxOn.value) playSfx("tap", true);384+ }385+386+ const placedCount = rectangles.value.length;387+ const total = puzzle.givens.length;388+ const solved = status.value === "solved";389+ const rank = getSolveRank({390+ elapsedMs: elapsedRef.current,391+ resets: resetsRef.current,392+ });393+325394 return (326- <section class="shikaku-game-shell">327- <div328- class={`shikaku-game-status shikaku-game-status--${329- getStatusTone(status.value)330- }`}331- >332- <div class="shikaku-game-status__meta">333- <strong>334- {status.value === "solved" ? "Solved" : "In progress"}335- </strong>336- <span>{message}</span>337- </div>338- <div class="shikaku-game-actions">395+ <section class="game">396+ <div class={`hud panel hud--${getStatusTone(status.value)}`}>397+ <span class="hud__rooms" aria-hidden="true">398+ {puzzle.givens.map((_, index) => (399+ <i key={index} class={index < placedCount ? "on" : ""} />400+ ))}401+ </span>402+ <span403+ class="hud__count"404+ aria-label={`${placedCount} of ${total} rooms`}405+ >406+ {placedCount}407+ <small>/{total}</small>408+ </span>409+ <span class="hud__msg" role="status">{message}</span>410+ <div class="hud__actions">411+ <button412+ class="btn btn--ghost btn--sm btn--icon"413+ type="button"414+ onClick={toggleSfx}415+ aria-label={sfxOn.value ? "Mute sounds" : "Unmute sounds"}416+ aria-pressed={sfxOn.value}417+ >418+ {sfxOn.value ? "♪" : "✕"}419+ </button>339420 <button340- class="shikaku-game-clear"421+ class="btn btn--ghost btn--sm"341422 type="button"342423 onClick={clearBoard}343424 disabled={readOnly && !resolvedResetEnabled}@@ -348,10 +429,10 @@ export default function ShikakuGame(props: ShikakuGameProps) {
348429 </div>349430 </div>350431 351- <div class="shikaku-game-board-wrap">432+ <div class="board-wrap">352433 <div353434 ref={boardRef}354- class="shikaku-board shikaku-board--fixed"435+ class={`board${shaking.value ? " is-shake" : ""}`}355436 style={{356437 gridTemplateColumns: `repeat(${puzzle.width}, minmax(0, 1fr))`,357438 }}@@ -367,19 +448,24 @@ export default function ShikakuGame(props: ShikakuGameProps) {
367448 const given = puzzle.givens.find((item) =>368449 item.row === row && item.col === col369450 );370- const rectangleIndex = rectangles.value.findIndex((rectangle) =>371- row >= rectangle.row &&372- row < rectangle.row + rectangle.height &&373- col >= rectangle.col &&374- col < rectangle.col + rectangle.width451+ const rectangle = rectangles.value.find((item) =>452+ row >= item.row && row < item.row + item.height &&453+ col >= item.col && col < item.col + item.width375454 );376455 const isActive = given ? activeClueId.value === given.id : false;377- const isPreview = previewCells.has(`${row},${col}`);378- const background = rectangleIndex >= 0379- ? COLORS[rectangles.value[rectangleIndex].clueId % COLORS.length]380- : isPreview381- ? "#dbeafe"382- : "#fffdf8";456+ const isPreview = !rectangle && previewCells.has(`${row},${col}`);457+ const color = rectangle458+ ? colorByClueId.get(rectangle.clueId) ?? getBoardColor(0)459+ : undefined;460+461+ const classes = ["cell"];462+ if (rectangle) classes.push("is-filled");463+ if (isPreview) classes.push("is-preview");464+ if (isActive) classes.push("is-active");465+ if (rectangle && rectangle.clueId === justPlacedClueId.value) {466+ classes.push("is-new");467+ }468+ if (rectangle && celebrate.value) classes.push("is-lit");383469 384470 return (385471 <button@@ -387,49 +473,77 @@ export default function ShikakuGame(props: ShikakuGameProps) {
387473 key={`${row}-${col}`}388474 data-row={row}389475 data-col={col}390- class="shikaku-board__cell"476+ class={classes.join(" ")}477+ tabIndex={-1}391478 style={{392- background,393- outline: isActive ? "3px solid #2563eb" : "none",394- outlineOffset: "-3px",479+ ["--cell-color" as string]: color,480+ animationDelay: celebrate.value481+ ? `${(row + col) * 45}ms`482+ : undefined,395483 }}396484 >397- {given ? given.value : ""}485+ {given ? <span class="cell__num">{given.value}</span> : null}398486 </button>399487 );400488 })}401489 </div>402490 </div>403491 404- <div class="shikaku-clue-strip" aria-label="Clue legend">405- {clueEntries.map((entry) => (406- <button407- key={entry.given.id}408- type="button"409- class={`shikaku-clue-token shikaku-clue-token--${410- getClueTone(entry.placed)411- }${entry.placed ? " is-placed" : ""}${412- entry.active ? " is-active" : ""413- }`}414- style={{ ["--token-color" as string]: entry.color }}415- onClick={() => {416- if (readOnly) {417- message.value = lockedMessage;418- return;419- }420- activeClueId.value = entry.given.id;421- message.value = `Clue ${entry.given.value} highlighted on board.`;422- }}423- aria-label={`Clue ${entry.given.value} at row ${424- entry.given.row + 1425- }, col ${entry.given.col + 1}`}426- >427- <span class="shikaku-clue-token__symbol" aria-hidden="true">428- {getClueSymbol(entry.given.value)}429- </span>430- <span class="shikaku-clue-token__value">{entry.given.value}</span>431- </button>432- ))}492+ <div class="tray">493+ {solved494+ ? (495+ <div class="result panel">496+ <div class="result__rank">{RANK_LABEL[rank]}</div>497+ <div class="result__stats">498+ <div>499+ <b>{formatElapsed(elapsedRef.current)}</b>500+ <span class="label">time</span>501+ </div>502+ <div>503+ <b>{resetsRef.current}</b>504+ <span class="label">resets</span>505+ </div>506+ <div>507+ <b>{streak.value}</b>508+ <span class="label">streak</span>509+ </div>510+ </div>511+ <div class="result__actions">512+ <ShareDailyStackButton date={puzzle.date} class="btn" />513+ </div>514+ </div>515+ )516+ : (517+ <div class="tokens" aria-label="Clue legend">518+ {clueEntries.map((entry) => (519+ <button520+ key={entry.given.id}521+ type="button"522+ class={`token${entry.placed ? " is-placed" : ""}${523+ entry.active ? " is-active" : ""524+ }`}525+ style={{ ["--token-color" as string]: entry.color }}526+ onClick={() => {527+ if (readOnly) {528+ message.value = lockedMessage;529+ return;530+ }531+ activeClueId.value = entry.given.id;532+ message.value = `Clue ${entry.given.value} highlighted.`;533+ sfx("tap");534+ }}535+ aria-label={`Clue ${entry.given.value} at row ${536+ entry.given.row + 1537+ }, col ${entry.given.col + 1}`}538+ >539+ <span class="token__sym" aria-hidden="true">540+ {getClueSymbol(entry.given.value)}541+ </span>542+ <span>{entry.given.value}</span>543+ </button>544+ ))}545+ </div>546+ )}433547 </div>434548 </section>435549 );@@ -468,13 +582,13 @@ function humanizeValidation(reason: string): string {
468582 case "out-of-bounds":469583 return "Outside the board.";470584 case "wrong-area":471- return "Area must match the clue.";585+ return "Box size must match the number.";472586 case "missing-given":473- return "Need one clue inside.";587+ return "The box needs one number inside.";474588 case "multiple-givens":475- return "Only one clue per region.";589+ return "Only one number per box.";476590 case "overlap":477- return "Regions cannot overlap.";591+ return "Boxes can't overlap.";478592 default:479593 return "That move is not valid.";480594 }@@ -0,0 +1,95 @@
1+/**2+ * Tiny synthesized arcade SFX. No assets, no network — square waves only.3+ * Audio context is created lazily on first user gesture.4+ */5+export type SfxName =6+ | "tap"7+ | "place"8+ | "remove"9+ | "invalid"10+ | "solve"11+ | "clear";12+13+const SFX_KEY = "shikaku:v1:sfx";14+let context: AudioContext | null = null;15+16+export function isSfxEnabled(17+ storage: Pick<Storage, "getItem"> | undefined,18+): boolean {19+ return storage?.getItem(SFX_KEY) !== "off";20+}21+22+export function setSfxEnabled(23+ storage: Pick<Storage, "setItem" | "removeItem"> | undefined,24+ enabled: boolean,25+): void {26+ if (!storage) return;27+ if (enabled) storage.removeItem(SFX_KEY);28+ else storage.setItem(SFX_KEY, "off");29+}30+31+interface Note {32+ freq: number;33+ at: number;34+ dur: number;35+ type?: OscillatorType;36+ gain?: number;37+}38+39+const PATTERNS: Record<SfxName, Note[]> = {40+ tap: [{ freq: 880, at: 0, dur: 0.03, gain: 0.05 }],41+ place: [42+ { freq: 523, at: 0, dur: 0.05 },43+ { freq: 784, at: 0.05, dur: 0.07 },44+ ],45+ remove: [46+ { freq: 660, at: 0, dur: 0.05 },47+ { freq: 392, at: 0.05, dur: 0.07 },48+ ],49+ invalid: [50+ { freq: 130, at: 0, dur: 0.08, type: "sawtooth", gain: 0.08 },51+ { freq: 110, at: 0.08, dur: 0.1, type: "sawtooth", gain: 0.08 },52+ ],53+ clear: [54+ { freq: 440, at: 0, dur: 0.05 },55+ { freq: 330, at: 0.05, dur: 0.05 },56+ { freq: 220, at: 0.1, dur: 0.08 },57+ ],58+ solve: [59+ { freq: 523, at: 0, dur: 0.09 },60+ { freq: 659, at: 0.09, dur: 0.09 },61+ { freq: 784, at: 0.18, dur: 0.09 },62+ { freq: 1047, at: 0.27, dur: 0.22 },63+ { freq: 784, at: 0.42, dur: 0.06, gain: 0.05 },64+ { freq: 1047, at: 0.48, dur: 0.3 },65+ ],66+};67+68+function getContext(): AudioContext | null {69+ if (typeof globalThis.AudioContext === "undefined") return null;70+ context ??= new AudioContext();71+ if (context.state === "suspended") void context.resume();72+ return context;73+}74+75+export function playSfx(name: SfxName, enabled = true): void {76+ if (!enabled) return;77+ const ctx = getContext();78+ if (!ctx) return;79+80+ const now = ctx.currentTime;81+ for (const note of PATTERNS[name]) {82+ const osc = ctx.createOscillator();83+ const amp = ctx.createGain();84+ osc.type = note.type ?? "square";85+ osc.frequency.value = note.freq;86+ const peak = note.gain ?? 0.07;87+ amp.gain.setValueAtTime(0.0001, now + note.at);88+ amp.gain.linearRampToValueAtTime(peak, now + note.at + 0.005);89+ amp.gain.setValueAtTime(peak, now + note.at + note.dur * 0.7);90+ amp.gain.exponentialRampToValueAtTime(0.0001, now + note.at + note.dur);91+ osc.connect(amp).connect(ctx.destination);92+ osc.start(now + note.at);93+ osc.stop(now + note.at + note.dur + 0.02);94+ }95+}@@ -0,0 +1,16 @@
1+/** Tapestry tones that sit on parchment and dark moss alike. */2+export const BOARD_COLORS = [3+ "#b8483d", // madder4+ "#d9a441", // ochre5+ "#3f9a86", // verdigris6+ "#3f5fb5", // lapis7+ "#7d4a86", // plum8+ "#b8672b", // rust9+ "#7f9a4c", // sage10+ "#5f6f88", // slate11+] as const;12+13+export function getBoardColor(index: number): string {14+ const safe = Number.isInteger(index) && index >= 0 ? index : 0;15+ return BOARD_COLORS[safe % BOARD_COLORS.length];16+}@@ -0,0 +1,26 @@
1+export type SolveRank = "squire" | "knight" | "paladin";2+3+export interface SolveResult {4+ elapsedMs: number;5+ resets: number;6+}7+8+const PALADIN_MAX_MS = 90_000;9+const KNIGHT_MAX_MS = 240_000;10+11+export function getSolveRank(result: SolveResult): SolveRank {12+ if (result.elapsedMs <= PALADIN_MAX_MS && result.resets === 0) {13+ return "paladin";14+ }15+ if (result.elapsedMs <= KNIGHT_MAX_MS && result.resets <= 1) {16+ return "knight";17+ }18+ return "squire";19+}20+21+export function formatElapsed(elapsedMs: number): string {22+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));23+ const minutes = Math.floor(totalSeconds / 60);24+ const seconds = totalSeconds % 60;25+ return `${minutes}:${String(seconds).padStart(2, "0")}`;26+}@@ -2,7 +2,7 @@ export const SITE_NAME = "rect.run";
22 export const SITE_SHORT_NAME = "rect.run";33 export const SITE_DESCRIPTION = "Daily Shikaku puzzles";44 export const SITE_THEME_COLOR = "#122411";5-export const SITE_BACKGROUND_COLOR = "#0f180d";5+export const SITE_BACKGROUND_COLOR = "#0a120a";66 77 export function buildPageTitle(label?: string): string {88 return label ? `${label} · ${SITE_NAME}` : SITE_NAME;@@ -158,3 +158,50 @@ function loadHistory(storage: Pick<Storage, "getItem">): PuzzleHistoryEntry[] {
158158 return [];159159 }160160 }161+162+/**163+ * Consecutive days (ending today, or yesterday if today is not yet solved)164+ * with at least one solved stack.165+ */166+export function getDailyStreak(167+ storage: Pick<Storage, "length" | "key" | "getItem">,168+ today: string,169+): number {170+ const solvedDates = new Set<string>();171+ const prefix = `${STORAGE_NAMESPACE}:progress:daily-`;172+173+ for (let index = 0; index < storage.length; index++) {174+ const key = storage.key(index);175+ if (!key?.startsWith(prefix)) continue;176+ const raw = storage.getItem(key);177+ if (!raw) continue;178+ try {179+ const progress = parsePuzzleProgress(JSON.parse(raw));180+ if (progress?.status === "solved") solvedDates.add(progress.date);181+ } catch {182+ continue;183+ }184+ }185+186+ const cursor = parseSlug(today);187+ if (!solvedDates.has(today)) cursor.setDate(cursor.getDate() - 1);188+189+ let streak = 0;190+ while (solvedDates.has(slugOf(cursor))) {191+ streak += 1;192+ cursor.setDate(cursor.getDate() - 1);193+ }194+ return streak;195+}196+197+function parseSlug(date: string): Date {198+ const [year, month, day] = date.split("-").map(Number);199+ return new Date(year, month - 1, day);200+}201+202+function slugOf(value: Date): string {203+ const y = value.getFullYear();204+ const m = String(value.getMonth() + 1).padStart(2, "0");205+ const d = String(value.getDate()).padStart(2, "0");206+ return `${y}-${m}-${d}`;207+}@@ -50,7 +50,7 @@ export default define.page(function App({ Component, url }) {
5050 <body5151 style={{5252 margin: 0,53- background: "#0f180d",53+ background: "#0a120a",5454 overscrollBehaviorX: "none",5555 overflowX: "hidden",5656 }}@@ -1,57 +1,37 @@
11 import { listAvailablePuzzleDates } from "@/lib/shikaku/catalog.ts";22 import { define } from "../utils.ts";3+import DeltaMark from "../components/DeltaMark.tsx";4+import KeepScene from "../components/KeepScene.tsx";35 import DailyStackLink from "../islands/DailyStackLink.tsx";4-import HomeDateStackEyebrow from "../islands/HomeDateStackEyebrow.tsx";6+import HomeStats from "../islands/HomeStats.tsx";57 import OfflineBadge from "../islands/OfflineBadge.tsx";6-import ShareDailyStackButton from "../islands/ShareDailyStreakButton.tsx";78 89 export default define.page(function Home() {910 const latest = listAvailablePuzzleDates().at(-1) ??1011 new Date().toISOString().slice(0, 10);1112 1213 return (13- <main class="shikaku-home shikaku-app-shell">14- <div class="shikaku-home__frame">15- <section class="shikaku-home__masthead">16- <p class="shikaku-home__brand">FelineStateMachine</p>17- <OfflineBadge />18- </section>14+ <main class="home">15+ <div class="home__frame">16+ <div class="home__scene" aria-hidden="true">17+ <i class="home__banner home__banner--left" />18+ <i class="home__banner home__banner--right" />19+ <KeepScene />20+ </div>1921 20- <section class="shikaku-home__hero">21- <div class="shikaku-home__hero-copy">22- <HomeDateStackEyebrow fallbackDate={latest} />23- <h1 class="shikaku-home__title">RECT.RUN</h1>24- <div class="shikaku-home__speech-stack" aria-label="Puzzle pitch">25- <p class="shikaku-home__speech">Daily grid.</p>26- <p class="shikaku-home__speech shikaku-home__speech--accent">27- Swipe. Solve.28- </p>29- </div>30- <div class="shikaku-home__actions">31- <DailyStackLink fallbackDate={latest} />32- <ShareDailyStackButton date={latest} />33- </div>22+ <div class="home__copy">23+ <h1 class="home__title">RECT.RUN</h1>24+ <HomeStats fallbackDate={latest} />25+ <div class="home__actions">26+ <DailyStackLink fallbackDate={latest} />27+ <a class="btn btn--ghost" href="/d">Archive</a>3428 </div>35-36- <div class="shikaku-home__hero-art" aria-hidden="true">37- <div class="shikaku-home__sky" />38- <div class="shikaku-home__moon" />39- <div class="shikaku-home__keep">40- <span class="shikaku-home__keep-tower shikaku-home__keep-tower--left" />41- <span class="shikaku-home__keep-tower shikaku-home__keep-tower--center" />42- <span class="shikaku-home__keep-tower shikaku-home__keep-tower--right" />43- <span class="shikaku-home__keep-wall" />44- <span class="shikaku-home__keep-gate" />45- <span class="shikaku-home__banner shikaku-home__banner--left">46- ✦47- </span>48- <span class="shikaku-home__banner shikaku-home__banner--right">49- ✦50- </span>51- </div>52- <div class="shikaku-home__lawn-grid" />29+ <div class="home__foot label">30+ <DeltaMark size={22} ring="white" variant="flicker" />31+ <span>Daily Shikaku</span>32+ <OfflineBadge />5333 </div>54- </section>34+ </div>5535 </div>5636 </main>5737 );Binary file.
@@ -1,5 +1,5 @@
1-const CORE_CACHE = "rect-run-core-v2";2-const RUNTIME_CACHE = "rect-run-runtime-v2";1+const CORE_CACHE = "rect-run-core-v3";2+const RUNTIME_CACHE = "rect-run-runtime-v3";33 const CORE_PATHS = [44 "/",55 "/d",@@ -8,6 +8,7 @@ const CORE_PATHS = [
88 "/icon-192.png",99 "/icon-512.png",1010 "/apple-touch-icon.png",11+ "/fonts/GeistPixel-Square.woff2",1112 "/register-sw.js",1213 "/sw.js",1314 ];@@ -0,0 +1,17 @@
1+import { assertEquals } from "@std/assert";2+3+import { formatElapsed, getSolveRank } from "@/lib/shikaku/rank.ts";4+5+Deno.test("paladin needs a clean, fast solve", () => {6+ assertEquals(getSolveRank({ elapsedMs: 60_000, resets: 0 }), "paladin");7+ assertEquals(getSolveRank({ elapsedMs: 60_000, resets: 1 }), "knight");8+ assertEquals(getSolveRank({ elapsedMs: 200_000, resets: 0 }), "knight");9+ assertEquals(getSolveRank({ elapsedMs: 200_000, resets: 2 }), "squire");10+ assertEquals(getSolveRank({ elapsedMs: 900_000, resets: 0 }), "squire");11+});12+13+Deno.test("formatElapsed renders m:ss", () => {14+ assertEquals(formatElapsed(0), "0:00");15+ assertEquals(formatElapsed(65_400), "1:05");16+ assertEquals(formatElapsed(-5), "0:00");17+});@@ -5,6 +5,7 @@ import {
55 buildHistoryKey,66 buildProgressKey,77 getAchievedStackIndex,8+ getDailyStreak,89 getMostRecentStackIndex,910 getPlayableStackIndex,1011 getSelectedStackIndex,@@ -285,3 +286,36 @@ Deno.test("getSelectedStackIndex prefers the highest visible stack over a lower
285286 286287 assertEquals(getSelectedStackIndex(storage, "2026-04-16"), 2);287288 });289+290+Deno.test("getDailyStreak counts consecutive solved days ending today", () => {291+ const storage = new MemoryStorage();292+ savePuzzleProgress(293+ storage,294+ createProgressFor("daily-2026-04-18-0", "solved", "t"),295+ );296+ savePuzzleProgress(297+ storage,298+ createProgressFor("daily-2026-04-17-1", "solved", "t"),299+ );300+ savePuzzleProgress(301+ storage,302+ createProgressFor("daily-2026-04-16-0", "in_progress", "t"),303+ );304+305+ assertEquals(getDailyStreak(storage, "2026-04-18"), 2);306+});307+308+Deno.test("getDailyStreak survives an unsolved today", () => {309+ const storage = new MemoryStorage();310+ savePuzzleProgress(311+ storage,312+ createProgressFor("daily-2026-04-17-0", "solved", "t"),313+ );314+ savePuzzleProgress(315+ storage,316+ createProgressFor("daily-2026-04-16-0", "solved", "t"),317+ );318+319+ assertEquals(getDailyStreak(storage, "2026-04-18"), 2);320+ assertEquals(getDailyStreak(storage, "2026-04-20"), 0);321+});