Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
1227 lines (1183 loc) · 65.6 KB
/
Copy pathindex.html
File metadata and controls
1227 lines (1183 loc) · 65.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5">
<title>Vacant · Ohio State</title>
<meta name="description" content="Find an empty classroom near you at Ohio State, free for as long as you need it.">
<meta name="color-scheme" content="dark">
<!-- Empty here on purpose. A Maps JavaScript API key restricted by HTTP referrer
to this origin, filled in at deploy; see docs/google-setup.md. Empty means
js/directions.js builds no provider and the Directions button never renders,
which is the app exactly as it shipped before it existed. -->
<meta name="google-maps-key" content="AIzaSyAYFxzL7aBdnDrbmzkgOQPYTSIm2J-4Xck">
<meta name="theme-color" content="#0b0d10">
<link rel="manifest" href="/Vacant/manifest.webmanifest">
<!-- With no icon link a browser asks the ORIGIN root for /favicon.ico, and
enesyilmazcode.github.io/favicon.ico belongs to the portfolio repo, not
this one. That was a 404 on every single page load. -->
<link rel="icon" href="/Vacant/favicon.ico" sizes="48x48 32x32 16x16">
<link rel="apple-touch-icon" href="/Vacant/apple-touch-icon.png">
<!-- Called deprecated everywhere and it costs a Lighthouse warning, but it is
still the only thing that puts old iOS into standalone mode at all, and a
campus runs on hand-me-down iPhones. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Vacant">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- No apple-touch-startup-image tags, on purpose. iOS ignores the manifest
background_color, so owning the splash means about fifteen PNGs covering
every current iPhone size and orientation, and that set breaks the day
Apple ships a new screen. Without them iOS draws a splash from the icon. -->
<style>
:root {
--bg: #0b0d10;
--fg: #eef1f5;
/* Measured against --bg: 8.20:1, and 7.51:1 over --card. The old #8b94a2
sat at 6.35 and 5.82, which passes AA and misses the 7:1 this screen is
held to, and the secondary line is where the window and the seat count
live. */
--dim: #a0a9b6;
--line: #1d232c;
--card: #121821;
--accent: #ff4d3d;
--you: #4cc2ff;
--warn: #ffb02e;
/* One step up from --card, --line and --accent, for the hover block at the
bottom of this sheet. Only --card-hi carries text and it carries --fg,
at 14.09:1; --dim on it is 6.72 and is kept off it, because this screen
is held to 7. Near-black on --accent-hi is 6.71:1, above the 5.91 the
same pairing measures on --accent. */
--card-hi: #1a2230;
--line-hi: #39434f;
--accent-hi: #ff6555;
/* How wide the sheet may get. Why, on #sheet below. */
--col: 32rem;
--safe-b: env(safe-area-inset-bottom, 0px);
--safe-t: env(safe-area-inset-top, 0px);
}
/* Author-origin `display` beats the UA sheet's `[hidden] { display: none }`
regardless of specificity, so `#ask { display: flex }` made the hidden
attribute inert and the question overlay could never be dismissed. The app
could not leave its first screen. */
[hidden] { display: none !important; }
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; }
/* 1rem, not 16px. A px base silently overrides the browser font size the
reader chose, which is the one setting most large-text users actually
change. */
body {
margin: 0; background: var(--bg); color: var(--fg); overflow: hidden;
font: 1rem/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-text-size-adjust: 100%;
}
/* Nothing here is decoration: without a visible ring the whole app is
unusable from a keyboard, and forced-colors throws the palette away, so
anything that carried meaning in colour has to carry it in a border too. */
:focus-visible {
outline: 3px solid var(--you); outline-offset: 2px; border-radius: 6px;
}
/* A heading is focused to carry the reader onto the new screen, not because
it can be pressed, and a 3px ring on something that does nothing when you
press Enter reads as a control that is broken. focusHeading already asks
for this with focus({ focusVisible: false }); this is the same answer for
an engine that has not implemented the option and drops it in silence.
Scoped to h2, so anything else carrying tabindex="-1" keeps its ring. */
h2[tabindex="-1"]:focus-visible { outline: none; }
@media (forced-colors: active) {
.row, .opt, .b-row, .pick-row, .bar-btn {
border: 1px solid ButtonBorder; forced-color-adjust: none;
background: ButtonFace; color: ButtonText;
}
.row.on, .opt.primary {
background: Highlight; color: HighlightText;
}
.strip, .unknown, #gate { border: 1px solid CanvasText; }
}
/* The map is the surface of the app, not a panel inside it. */
#map {
position: fixed; inset: 0; width: 100%; height: 100%; display: block;
filter: blur(2.5px) saturate(.95); transform: scale(1.03);
transition: filter .9s ease, transform .9s ease, opacity .28s ease;
}
#map.settled { filter: none; transform: none; }
/* No destination on it, so it is not on screen. It keeps rendering, composed
for the band it will have when it comes back, so the first tap reveals a
finished frame rather than one built while you watch; js/sheet.js rests the
sheet over the whole of it. pointer-events because a pan on an invisible
canvas still latches state.userMoved. */
body.nomap #map { opacity: 0; pointer-events: none; visibility: hidden; }
@media (prefers-reduced-motion: reduce) {
#map { transition: none; }
}
/* Geometry lives in the sprite, paint comes from the use site. Putting fill
and stroke on the sprite root does not reach a <use> clone, which renders
the legs as filled black wedges. */
.ico {
width: 1.15em; height: 1.15em; flex: none;
fill: none; stroke: currentColor; stroke-width: 2;
stroke-linecap: round; stroke-linejoin: round;
}
[dir="rtl"] .ico-mirror { transform: scaleX(-1); }
/* Unconditional, unlike .ico-mirror. A back arrow follows the writing
direction; "the day before this one" is left of it in every direction,
because the grid it steps through runs top to bottom. */
.ico.flip { transform: scaleX(-1); }
.sr {
position: absolute; width: 1px; height: 1px; margin: -1px;
padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
/* --- the question --- */
/* justify-content: center on a scroll container puts overflow ABOVE the
scroll origin, where nothing can reach it. Measured at 393x852 with root
53px (AX5): content 1989px in an 852px box, the 30 min button at top -644,
and scrollTop, scrollIntoView and Tab all left it there. safe centring
falls back to flex-start the moment the content stops fitting, which is
exactly when the centring stopped being worth anything. The plain value
stays above it for engines that do not parse the safe keyword. */
#ask {
position: fixed; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; justify-content: safe center;
gap: 1.05rem; padding: 1.5rem;
padding-bottom: calc(1.5rem + var(--safe-b));
background: radial-gradient(90% 55% at 50% 46%, rgba(11,13,16,.78), rgba(11,13,16,.55) 62%, rgba(11,13,16,.9));
overflow-y: auto;
}
/* The clamp floor is in rem, so at a large root it wins and the wordmark grows
past the screen: measured with device emulation at 393px and root 53px it
rendered 127.2px and 433px wide inside a 234px content box, and #ask
scrolled sideways at 492 against 393. "Vacant." measures 3.40 times its own
font size at every size tried (51.09px -> 174px, 76.8px -> 261px, 127.2px
-> 433px), so the last term caps it at what fits with a little to spare.
Below AX5 it never binds: at 393px and root 16px it works out to 98.6px
against the 51.09px the clamp already picked.
The max() term is the other end of the same problem: 13vw reaches the
3.4rem ceiling at 418.5px and nothing binds again above it, so the wordmark
was the same 54.4px on a 1900px window as on a 419px phone. 6vw is the rate
that lets it grow, and it is picked to clear every phone in portrait: 6vw
only passes 3.4rem at 906.7px. That does NOT clear a 430x932 phone turned
sideways, and an earlier draft of this comment claimed it did. Measured at
932x430 the wordmark is 55.92px against main's 54.4, and the column cap
binds too: the sheet's children go from 932px wide to 512. Landscape on a
phone is the one place this file's "nothing moves below a laptop" rule
bends, and a 512px column inside a 163px-tall peeked sheet is the better
answer anyway, so it bends deliberately rather than by accident. Portrait
is untouched: 50.7px at 390, 54.4px at 430 and 820, unchanged; 80px at
1900. The 5rem ceiling arrives at
1333px, and is 5rem because "Vacant." measures 3.40 times its font size, so
80px is 272px of wordmark inside the 24rem .opts column below it. */
#ask h1 {
margin: 0; font-weight: 800;
font-size: min(max(clamp(2.4rem, 13vw, 3.4rem), min(6vw, 5rem)), calc((100vw - 3rem) / 3.5));
letter-spacing: -.04em; line-height: 1;
text-shadow: 0 2px 22px rgba(0,0,0,.75);
}
#ask h1 span { color: var(--accent); }
#ask-q {
width: 100%; max-width: 24rem; display: flex; flex-direction: column;
align-items: stretch; gap: .75rem;
}
/* width:100% is here to make the buttons equal width, nothing more. An
earlier comment claimed it fixed a max-content overflow that clipped
"2 hours" at 390px. It did not: measured, the old row was 312px inside a
342px content box and never overflowed at 320, 375, 390 or 430. The
apparent clipping was headless Chrome ignoring --window-size for layout
and centring the frame in a wider viewport. */
.opts {
display: flex; gap: .55rem; flex-wrap: wrap; justify-content: center;
width: 100%; max-width: 24rem;
}
/* The side padding is capped in vw because rem grows with the root and the
screen does not. At 393px with root 53px a 1rem pad put the button's
smallest possible width at 249px inside a 234px content box, which is
8px of sideways scroll on the question screen. At every ordinary size the
rem is the smaller of the two and nothing moves: 15.7px against 16px at
393px and root 16px. */
.opt {
appearance: none; border: 1px solid var(--line); background: var(--card); color: var(--fg);
font: inherit; font-weight: 650; padding: .95rem min(1rem, 4vw); border-radius: 999px;
min-height: 52px; cursor: pointer;
}
/* `flex: 1 1 6rem` belongs to the row and only to the row.
It was on `.opt` itself, and #ask-pick and #gate-go are `.opt` buttons that
are direct children of #ask, which is a COLUMN flex container. On a column
the main axis is vertical, so the 6rem was a HEIGHT and the grow factor ate
the leftover column. Measured at 393x852 with the root at 16px, nothing
exotic: "Pick your building instead" rendered 229x371 and "Show nearest
buildings" 208x96, both as 999px pill radii, so they came out as two giant
ovals covering the lower two thirds of the screen. */
.opts .opt { flex: 1 1 6rem; }
/* A standalone .opt is a button, not a column. flex:none stops it growing and
max-width stops it running the full width on a tablet.
The radius is half the 52px min-height rather than 999px, which is the same
pill on one line and stops being an egg when the label wraps: at a 32px root
"Show nearest buildings" is three lines and 202px tall, and a 999px radius
on that box is an oval. */
#ask > .opt, #gate > .opt {
flex: none; max-width: 22rem; width: 100%; border-radius: 26px;
}
/* White on this red measures 3.29:1, under the 4.5 floor, on the one control
every session goes through. Near-black on the same red is 6.38:1. */
.opt.primary { background: var(--accent); border-color: var(--accent); color: #0b0d10; }
.opt:active { transform: scale(.97); }
/* pointer-events blocks taps but NOT keyboard activation, so Tab+Enter
before boot() resolved reached choose() with no data and threw. The
buttons carry `disabled` in the markup and app.js clears it when ready;
this is only the dimming. */
#ask:not(.ready) .opts { opacity: .45; }
#ask:not(.ready) .loading { opacity: 1; }
/* boot() rejected. There is no answer coming, so the line has to stop or the
screen goes on claiming it is still looking. Same specificity as the rule
above, so it has to sit under it. */
#ask.failed .loading { opacity: 0; }
.loading { color: var(--dim); font-size: .82rem; opacity: 0; transition: opacity .3s; }
/* The quiet lines under the question. The term used to be one and is not any
more; what is left is actionable -- the index is old, you are somewhere
else, here is what boot failed on. */
.prov { color: var(--dim); font-size: .8rem; text-align: center; margin: 0; max-width: 26rem; }
#gate {
max-width: 26rem; text-align: center; border: 1px solid var(--line);
border-radius: 14px; padding: .9rem 1rem;
display: flex; flex-direction: column; gap: .6rem; align-items: center;
}
#gate h2 { margin: 0; font-size: 1.05rem; }
/* Orange only for a refusal. This card wore --warn on every state it can
reach, and the ordinary night is one of them: 11:40pm on a Monday is the
answer, not a fault. paintGate puts the class on for the six states
refusedState dresses, and takes it off for the clock. */
#gate.refusal { border-color: var(--warn); }
#gate.refusal h2 { color: var(--warn); }
#gate p { margin: 0; color: var(--dim); font-size: .88rem; }
#gate-d { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .8rem; }
#stale.banner {
border: 1px solid var(--warn); border-radius: 12px; padding: .7rem .9rem; color: var(--warn);
}
/* --- the back arrow, over the map, never on the root --- */
#back, #menu {
position: fixed; top: calc(var(--safe-t) + .6rem); left: .6rem; z-index: 3;
appearance: none; border: 1px solid var(--line); background: rgba(11,13,16,.72);
color: var(--fg); font: inherit; min-width: 44px; min-height: 44px;
padding: .4em; border-radius: 999px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
/* The menu, and the surface that closes it. One element does both: the whole
screen catches the tap that means "not this", and the panel inside it hangs
under the button that opened it. */
/* Darkened, because the panel is a decision and the screen behind it is not
available until it is made. It is also what makes "press off it" look like
something you can do. */
#menu-pop { position: fixed; inset: 0; z-index: 6; background: rgba(6,8,11,.55); }
.m-panel {
position: absolute; top: calc(var(--safe-t) + 3.4rem); left: .6rem;
min-width: 13rem; max-width: min(20rem, calc(100vw - 1.2rem));
background: rgba(18, 24, 33, .93);
-webkit-backdrop-filter: blur(14px) saturate(1.3);
backdrop-filter: blur(14px) saturate(1.3);
border: 1px solid rgba(255,255,255,.14); border-radius: 14px;
padding: .3rem; display: flex; flex-direction: column;
box-shadow: 0 12px 32px rgba(0,0,0,.55);
}
.m-item {
appearance: none; border: 0; background: none; color: var(--fg); font: inherit;
text-align: left; padding: .7rem .8rem; border-radius: 10px;
min-height: 44px; cursor: pointer;
}
.m-item:hover { background: rgba(255,255,255,.07); }
.m-item:focus-visible { background: rgba(255,255,255,.12); }
/* The panel opens over the corner the plate is next to, and a plate sticking
out from behind it reads as a collision rather than as a layer: measured on
the card frame, "Cunz Hall" went under the panel and "160 / 42 seats" did
not. So the plate stands down while the menu is up. */
body.menuing .c-plate { opacity: 0; }
.c-plate { transition: opacity .16s linear; }
@media (prefers-reduced-motion: reduce) {
.c-plate { transition: none; }
}
/* --- the card ---
The photograph IS the screen. Colin, who uses this on campus, asked for "a
single button that just finds the nearest empty classroom and if its full u
swipe and gives u next best"; Enes asked for the room to be the picture,
edge to edge: "the image of the classroom is the most important part as it
provides a good visual aspect and a visual reference".
So there is no card floating on a background. There is a room, a back arrow
over it, one translucent plate near the top, and the two verdicts on the
bottom corners. Everything the list says around its rows -- the count, the
walk cap, the coverage paragraph -- stays on the list.
Three earlier shapes are gone and worth not repeating. A 22rem card with the
room number at 3.6rem: no picture at all. The same card filled by a cover
crop: 55% of the phone was empty ground under it. A 4:5 card holding the
photograph contained, with a blurred copy behind: the whole room was
visible, and it read as a small picture in a lot of dark. */
#card {
position: relative;
padding: 0;
/* The pane does not scroll and the photograph does not letterbox: this
screen is exactly one viewport, and a thrown card leaves sideways out of
it. */
overflow: hidden;
}
.c-deck { position: absolute; inset: 0; }
.c-card {
position: absolute; inset: 0; overflow: hidden;
background: var(--card-hi);
touch-action: none; cursor: grab; user-select: none; -webkit-user-select: none;
}
.c-media { position: absolute; inset: 0; overflow: hidden; }
/* Only while the finger is off it. During a drag the transform is written
every frame and a transition would lag the thumb. */
.c-card.snap { transition: transform .26s cubic-bezier(.2,.8,.3,1), opacity .26s ease; }
/* A canvas, not an img, because the picture is WARPED to fit.
The arithmetic that forces it: these photographs are 3:2 and the screen is
about 1:2.2, so a cover crop can only ever show a 277x600 slice of the
900x600 source -- 31% of the width, full height -- and since every one of
them is shot from the back of the room, half of that column is foreground
carpet. Measured, the first full-bleed build was 60% carpet with the
screen, the boards and both ends of the room gone. Cropping the source
makes the column narrower, not wider. Containing it shows the whole room
and leaves two bands of something else.
Enes's idea, and it is the one that works: stretch the TOP. The top of the
frame is ceiling, it is flat, and the plate sits over it, so spending most
of the screen's height on a few rows of it costs nothing anybody can see --
and it buys the width back for the part of the room you are looking at.
js/app.js drawWarp() does the mapping and says how. */
.c-photo {
position: absolute; z-index: 1; inset: 0; width: 100%; height: 100%;
/* Loaded per card and never precached, so it arrives after the text. Fading
it in stops the room appearing as a flash under words already being read.
js/app.js adds .on when the decode finishes. */
opacity: 0; transition: opacity .35s ease;
}
.c-photo.on { opacity: 1; }
/* An img is natively draggable, and a pointerdown on one starts a browser
image-drag that CANCELS the pointer stream. The swipe then saw a
pointercancel instead of a move and put the card straight back, so a card
with a photograph on it could not be dragged at all. The picture has no
interaction of its own, so the events belong to the card under it. */
.c-photo, .c-scrim { pointer-events: none; }
/* Under the plate and under the buttons, and nowhere else. A single wash over
a whole room greys out the thing the screen exists to show. */
.c-scrim {
position: absolute; z-index: 1; inset: 0;
background:
linear-gradient(180deg, rgba(11,13,16,.55) 0%, rgba(11,13,16,.28) 14%, rgba(11,13,16,0) 32%),
linear-gradient(0deg, rgba(11,13,16,.55) 0%, rgba(11,13,16,0) 18%);
}
/* No photograph for this room, and 119 of the 425 have none. A flat panel
rather than a broken-image glyph: the answer is the words, and the picture
was always the bonus. */
.c-card.plain { background: var(--card-hi); }
.c-card.plain .c-scrim { background: none; }
/* The plate. Everything the card SAYS is on it, so the text has one contrast
problem to solve instead of one per line, and it reads at the same weight
over a dark lecture hall and a wall of windows. It is bigger than it was:
this is the only text on the screen and it is read at arm's length.
Narrower than the phone and centred in it, so it reads as a label ON the
photograph rather than as a bar across it. max-width rather than a pair of
insets -- the insets were what made it the width of the screen -- and it was
inset on the left before that, to dodge a back arrow this screen no longer
has.
Frosted: the reference Enes sent is a plate you can see the room through,
and a flat 78% panel is not that. backdrop-filter was taken out of this rule
once, because it made scripts/shoot.mjs refuse the frame as still moving;
that check compares within a tolerance now, for the raster noise a
photograph brings, and this sits well inside it. */
.c-plate {
position: absolute; z-index: 2; top: calc(var(--safe-t) + 5.6rem);
left: 50%; transform: translateX(-50%);
width: calc(100% - 1.8rem); max-width: 20rem;
background: rgba(18, 24, 33, .55);
-webkit-backdrop-filter: blur(14px) saturate(1.3);
backdrop-filter: blur(14px) saturate(1.3);
border: 1px solid rgba(255,255,255,.14); border-radius: 16px;
padding: .8rem .9rem; text-align: center;
}
.c-b {
margin: 0; font-size: clamp(1.35rem, 7vw, 1.8rem); font-weight: 700;
letter-spacing: -.01em; line-height: 1.15;
}
/* One line, three facts. The window comes first because it is the answer to
the question that was asked; the walk and the seats are what you weigh it
against. */
.c-facts {
margin: .35rem 0 0; color: var(--dim); font-size: .95rem;
display: flex; align-items: center; justify-content: center; gap: .45rem;
flex-wrap: wrap;
}
.c-facts .sep { opacity: .45; }
.c-facts .warn { color: var(--warn); }
/* What the answer has to admit, when it has anything to admit -- a window
shorter than the one you asked for, a minute with nothing free in it, a
campus whose published doors are all shut. It rides on the plate rather
than above it, because the room fills the screen and anything in the flow
before the deck would be painted over by it. Absent 94.1% of the time. */
.c-admits { margin: .5rem 0 0; border-top: 1px solid rgba(255,255,255,.1); padding-top: .45rem; }
.c-admits .strip, .c-admits p {
margin: 0; color: var(--warn); font-size: .78rem; text-align: center;
}
/* The two verdicts, painted on the card as it moves so the gesture says what
it will do before it is finished. Opacity is driven from js/app.js.
Each sits on the side of the card that stays ON SCREEN, which is the
opposite side to the direction of travel: a card thrown left takes its left
edge away first, so NEXT lives on the right. The first build had them the
other way round and the word telling you what you were about to do slid off
the screen exactly as you committed to it. */
.c-stamp {
position: absolute; z-index: 3; top: 40%; font-size: 1.15rem; font-weight: 800;
letter-spacing: .08em; padding: .4rem .8rem; border-radius: 10px;
border: 2px solid currentColor; opacity: 0; pointer-events: none;
background: rgba(11,13,16,.5);
}
.c-stamp.no { right: 1.4rem; color: #fff; transform: rotate(12deg); }
.c-stamp.yes { left: 1.4rem; color: var(--accent); transform: rotate(-12deg); }
/* On the picture, at its two bottom corners, which is where a thumb already
is. The side each one sits on is the direction its swipe goes. */
.c-acts {
position: absolute; z-index: 2; left: 0; right: 0;
bottom: calc(1.1rem + var(--safe-b));
display: flex; justify-content: space-between; align-items: center;
margin: 0; padding: 0 1.1rem; pointer-events: none;
}
.c-acts .c-act { pointer-events: auto; }
/* No disc. A ring around a glyph is a second shape to read before the glyph
itself, and on a photograph it is a second thing to keep legible. The
target stays 56px square -- that is the thumb, not the ink -- and the ink
grows to fill it. The drop shadow is what holds a white glyph over a lit
ceiling; a translucent disc was doing that job and doing it worse. */
.c-act {
appearance: none; width: 60px; height: 60px; padding: 0;
border: 0; background: none; color: var(--fg); cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.c-act .ico {
width: 40px; height: 40px; stroke-width: 2.1;
filter: drop-shadow(0 1px 3px rgba(0,0,0,.9)) drop-shadow(0 0 10px rgba(0,0,0,.55));
}
.c-act-label { display: none; }
.c-act.yes { color: var(--accent); }
.c-act.yes .ico { stroke-width: 2.6; }
.c-act:active { transform: scale(.9); }
/* Nothing here any more. The way to the ranking was a pill reading
"1 of 35 - see all", then three bars opposite the back arrow, and both were
one more thing to work out on a screen whose whole argument is that one
room is the answer. It is a downward throw on the card now, and the card's
accessible name says so. */
.c-end { margin: 0; text-align: center; color: var(--dim); font-size: .9rem; }
/* The end of the deck is the one place a control for the list survives: no
photograph, no gesture, and no room left to swipe, so it is the only thing
there is to offer. */
#card.done .c-more {
appearance: none; border: 1px solid var(--line-hi); background: var(--card);
color: var(--fg); font: inherit; font-size: .9rem;
padding: .6rem 1rem; border-radius: 10px; cursor: pointer;
}
/* The end of the deck has no photograph and no gesture, so it is laid out
like every other message pane in this app rather than like a screen. */
#card.done {
display: flex; flex-direction: column; align-items: center;
justify-content: center; justify-content: safe center;
gap: .85rem; padding: 1rem;
}
@media (prefers-reduced-motion: reduce) {
.c-card.snap { transition: none; }
}
/* The sheet is the photograph's frame on this screen, so it has no frame:
no rounded top, no border, no grip, and it starts at the top of the phone.
js/sheet.js rests it at the full viewport height to match. */
body.carding #sheet { border-radius: 0; border-top: 0; background: none; }
body.carding #sheet .handle { display: none; }
/* No back arrow on the card. It was a third piece of chrome on a photograph
for a screen you leave by throwing the card down, and the duration behind it
is one tap to set again. The gesture is not printed anywhere: Enes,
"dont have text that says swipe down, it should be something ppl learn".
It is in the card's accessible name, where only a screen reader reads it,
because a gesture nothing announces is not learnable at all by somebody who
cannot see the card move.
Which of the two corner controls a screen gets is decided once, in
showPane(), rather than here as well: this rule used to hide #back on the
card while showPane un-hid it, and two mechanisms for one fact is how they
drift. */
/* On the two screens the menu owns, it loses its disc and grows to the size of
the two verdicts on the card, so the set reads as one set. A bordered pill
over a room reads as a control panel bolted to the picture, and the same
button two sizes on two consecutive screens reads as two buttons: Enes,
"in the 3rd picture the hamburger icon like the 3 lines is different, make
image 3 match image 2". The drop shadow is what the disc was for, and it
works over a photograph and a night map alike. */
body.carding #menu, body.waying #menu {
border: 0; background: none; padding: 0;
min-width: 60px; min-height: 60px; width: 60px; height: 60px;
top: calc(var(--safe-t) + 1.1rem);
/* The column rule below is (1,0,0) and this is (1,1,1), so writing a bare
1.1rem here beat it on every window -- and these are the only two screens
the menu appears on, which made that rule dead. Measured at 1900px: the
hamburger sat at x=17 while the 512px column started at x=694. */
left: max(1.1rem, env(safe-area-inset-left, 0px), calc(50% - var(--col) / 2 + .5rem));
}
body.carding #menu .ico, body.waying #menu .ico {
width: 40px; height: 40px; stroke-width: 2.1;
filter: drop-shadow(0 1px 3px rgba(0,0,0,.9)) drop-shadow(0 0 10px rgba(0,0,0,.55));
}
body.carding .m-panel, body.waying .m-panel {
top: calc(var(--safe-t) + 4.6rem);
left: max(1.1rem, env(safe-area-inset-left, 0px), calc(50% - var(--col) / 2 + .5rem));
}
/* The way: the map with the room lit on it, one plate naming the room, and the
ranking peeked underneath. Enes on the schedule, "the list of other classes
is irrelevant", and on the rooms a message later, "the take it and the way
should have the other nearby classes at the bottom back". */
/* The map is fixed behind everything, the ranking is in the sheet below, so
the way itself is one plate over the canvas between them. It lets every
pointer through: the map is meant to be panned, and the plate is a label
rather than a control. */
#way { position: fixed; inset: 0; z-index: 2; pointer-events: none; }
/* --- the answer --- */
/* The sheet carries the column width itself, rather than running the full
width with a narrow strip of content floating in it.
Capping only the children was the first attempt and it looked broken at a
laptop size: a 1900px black slab with 512px of rows in the middle of it and
two dead margins that belonged to nothing. The sheet IS the card, so the
card is what narrows, and the map it sits over shows on both sides.
Centred with margin-inline against left:0/right:0, NOT with a translate.
`left: 50%; transform: translateX(-50%)` centres just as well and at 393px
it translates by -196.5px, so every glyph in the sheet lands on a half
pixel and re-antialiases: scripts/shoot.mjs shoots at 393x852 and all four
frames came back changed on a diff that was supposed to be desktop-only.
Below 32rem the max-width never binds and the margins collapse to nothing,
so a phone gets back exactly the full-bleed sheet it had. */
#sheet {
position: fixed; left: 0; right: 0; margin-inline: auto;
max-width: var(--col);
bottom: 0; height: 38%;
display: flex; flex-direction: column; z-index: 2;
background: linear-gradient(180deg, rgba(11,13,16,.82), var(--bg) 22%);
border-top: 1px solid var(--line); border-radius: 18px 18px 0 0;
padding-bottom: var(--safe-b);
}
#sheet.snap { transition: height .22s ease; }
@media (prefers-reduced-motion: reduce) {
#sheet.snap { transition: none; }
}
/* Why the sheet is capped, and what the cap is worth.
Everything in here is `flex: 1 1 auto` inside a box that was left:0 right:0
with no cap: on a phone that flex is what makes a row fit, on a laptop it
is what tears it apart. Three broken controls, one cause, so one cap rather
than three rules. Measured in headless Chrome at 1900x1000 on the ranked
list, before this rule and after it. The row's name column 1777.2 ->
389.2px, so "Psychology Building 115" and its "3 min" went from x=20.6 and
x=1813.8 to x=714.6 and x=1119.8. The four chips 459.3/445.5/445.5/508.2 ->
112.3/98.5/98.5/161.2, where the first figure is a 464px pill for a
two-character label. #origin-where 1823.4 -> 435.4px, with the ✕ that
clears it at x=1842.6 -> x=1148.6; that one also puts the ellipsis on
#origin-where span back in reach, since the longest name in the index wants
418px in a span that had 418 and now has 383.
The SHEET carries that cap, not its children, and this paragraph used to
say the opposite: that only the column may narrow, because the sheet's top
border is a horizon line across the map and "a card floating over the map
is a second design, which docs/BLUEPRINT.md rules out for this app".
docs/BLUEPRINT.md rules out no such thing. It does not contain the word
sheet, or card, or panel, and it says of itself that where it disagrees
with the code the code is right. The rule was invented and then attributed.
What the full-width sheet actually looked like at 1920x1000 was a black
slab the width of the window with 512px of rows adrift in the middle of it
and two margins belonging to nothing.
32rem is 512px at the default root, so it cannot bind on a phone: measured
at 390 and 430px the sheet comes back x=0 and full width, and at 1280 and
1920 it is 512px at x=384 and x=704. In rem rather than px so it grows with
the reader's font size and keeps clear of the large-text reflow, which is
the container query below.
Centred with margin-inline, never with a transform. `left: 50%` plus
translateX(-50%) centres just as well and at the 393px scripts/shoot.mjs
shoots at it translates by -196.5px, landing every glyph in the sheet on a
half pixel: all four frames with sheet content in them came back
re-antialiased, which is how that got caught.
The children carry nothing now, so a pane is the width of the sheet again,
which is the width the four `container-type: inline-size` panes measure
their @container (max-width: 18em) reflow against. */
/* The grip was decorative for a whole release. It is the drag handle now, so
it gets a 26px band around it rather than four pixels of hairline. */
#sheet .handle {
display: flex; align-items: center; justify-content: center;
height: 26px; flex: none; cursor: grab; touch-action: none;
}
#sheet .grip { width: 34px; height: 4px; border-radius: 2px; background: var(--line); }
/* min-height:0 is load-bearing: without it a flex item will not shrink below
its content, so the pane grows past the sheet instead of scrolling. */
.pane {
flex: 1 1 auto; min-height: 0;
overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
touch-action: pan-y; padding: 0 0 1rem;
}
/* --- where the answer is measured from --- */
#origin {
display: flex; align-items: stretch; gap: .5rem; flex: none;
padding: 0 .7rem .5rem; border-bottom: 1px solid var(--line);
}
.bar-btn {
appearance: none; background: var(--card); border: 1px solid var(--line); color: var(--fg);
font: inherit; font-size: .84rem; border-radius: 999px; min-height: 44px;
padding: .3rem .9rem; cursor: pointer; display: flex; align-items: center; gap: .4rem;
}
#origin-where { flex: 1 1 auto; min-width: 0; text-align: left; }
#origin-where span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#origin-where b { font-weight: 650; }
#origin-clear { flex: none; min-width: 44px; justify-content: center; }
.strip {
margin: 0; padding: .6rem 1.1rem .7rem; color: var(--warn);
font-size: .88rem; border-bottom: 1px solid var(--line);
}
/* The list's one statement of the question it is answering, now that the
duration chips are gone. It is prose, not a control: no border, no target,
nothing to press. Weighted like .prov, which is the other line in this app
that says what the answer was computed from, but ranged left with the rows
rather than centred, because it heads a column of text instead of sitting
under a title. */
.asked {
margin: 0; padding: .55rem 1.1rem .1rem; color: var(--dim); font-size: .8rem;
}
.asked b { color: var(--fg); font-weight: 600; }
/* Measured at AX5, root 53px: "Independence" alone is 345px inside a 273px
column, so without a break inside the word the name is clipped and the row
overflows its own box by 14px. The name is the one field carried while
walking, so it breaks rather than truncates. */
.row, .b-row, .pick-row .pn { overflow-wrap: anywhere; }
.row {
appearance: none; width: 100%; text-align: left; background: transparent; color: inherit;
font: inherit; border: 0; border-left: 3px solid transparent;
padding: .7rem 1.1rem; min-height: 56px; cursor: pointer;
display: grid; gap: .12rem 1rem;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas: "name walk" "win chev";
}
.row + .row { border-top: 1px solid var(--line); }
.row.on { border-left-color: var(--accent); background: rgba(255,77,61,.07); }
.r-name { grid-area: name; font-weight: 700; letter-spacing: -.01em; min-width: 0; }
.r-walk { grid-area: walk; display: flex; align-items: center; gap: .3rem; white-space: nowrap; }
.r-win { grid-area: win; color: var(--dim); font-size: .84rem; min-width: 0; }
.r-chev { grid-area: chev; color: var(--dim); display: flex; align-items: center; justify-content: flex-end; }
/* rem inside a media query resolves against 16px forever, so the large-text
reflow has to be a container query in em or it never fires. */
#list, #near, #pick, #browse, #room { container-type: inline-size; container-name: list; }
@container list (max-width: 18em) {
.row { grid-template-columns: minmax(0, 1fr); grid-template-areas: "name" "walk" "win" "chev"; }
.row .r-walk, .row .r-chev { justify-self: start; }
}
.warn { color: var(--warn); }
.empty { color: var(--dim); padding: 1.4rem 1.1rem; margin: 0; }
.foot { color: var(--dim); font-size: .8rem; padding: 1rem 1.1rem 0; margin: 0; border-top: 1px solid var(--line); }
.foot b { color: var(--fg); font-weight: 600; }
.foot-acts { display: flex; flex-wrap: wrap; gap: .5rem; padding: .7rem 1.1rem 0; }
h2.msg { margin: 0; padding: .9rem 1.1rem .3rem; font-size: 1.05rem; letter-spacing: -.01em; }
/* --- the buildings screen, for the hours the schedule does not cover --- */
.why { margin: 0; padding: .2rem 1.1rem .8rem; color: var(--fg); font-size: .92rem; }
.grp {
margin: 0; padding: .5rem 1.1rem .35rem; font-size: .74rem; font-weight: 700;
letter-spacing: .08em; text-transform: uppercase; color: var(--dim);
border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
}
.b-row {
appearance: none; width: 100%; text-align: left; background: transparent; color: inherit;
font: inherit; border: 0; padding: .7rem 1.1rem; min-height: 56px; cursor: pointer;
display: grid; gap: .12rem 1rem; grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas: "name walk" "sub hours";
}
.b-row + .b-row { border-top: 1px solid var(--line); }
.b-row .b-hours { grid-area: hours; color: var(--dim); font-size: .84rem; white-space: nowrap; }
/* --- the building picker --- */
.shortcuts { display: flex; flex-wrap: wrap; gap: .45rem; padding: .3rem 1.1rem .7rem; }
.shortcuts .bar-btn { flex: 1 1 8rem; justify-content: center; }
.pick-row {
appearance: none; width: 100%; text-align: left; background: transparent; color: inherit;
font: inherit; border: 0; padding: .6rem 1.1rem; min-height: 44px; cursor: pointer;
display: flex; align-items: center; gap: .7rem;
}
.pick-row + .pick-row { border-top: 1px solid var(--line); }
.pick-row .pn { flex: 1 1 auto; min-width: 0; }
.pick-row .ps { color: var(--dim); font-size: .8rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.pick-row .pc { color: var(--dim); font-size: .84rem; white-space: nowrap; }
.pick-pin {
display: flex; align-items: center; justify-content: space-between; gap: .7rem;
margin: .3rem .7rem .7rem; padding: .65rem .75rem;
border: 1px solid var(--accent); border-radius: 12px; background: var(--card);
font-size: .9rem;
}
/* A container query carries no specificity of its own, so this block has to
sit BELOW .b-row and .pick-row or the plain rules win on source order.
.b-row had the container name and no rule inside the query at all: measured
at 393px with root 53px, all 53 building rows computed a 0px name column
against a 342px walk column and #near scrolled sideways at 454 to 393. */
@container list (max-width: 18em) {
.b-row { grid-template-columns: minmax(0, 1fr); grid-template-areas: "name" "walk" "sub" "hours"; }
.b-row .r-walk { justify-self: start; }
.b-row .b-hours { white-space: normal; }
.pick-row { flex-wrap: wrap; }
.pick-row .pc { white-space: normal; }
/* 8rem is 424px once the root is 53px, and a shortcut label is one flex
item that will not shrink under its longest word, so the picker scrolled
sideways by 6px. One button per line, and the label breaks the way the
row names already do. */
.shortcuts .bar-btn { flex-basis: 100%; min-width: 0; overflow-wrap: anywhere; }
}
#find {
flex: none; display: flex; gap: .5rem; padding: .5rem .7rem;
border-top: 1px solid var(--line);
}
#find input {
appearance: none; flex: 1 1 auto; min-width: 0; background: var(--card);
border: 1px solid var(--line); color: var(--fg); font: inherit; font-size: .95rem;
padding: .5rem .9rem; border-radius: 999px; min-height: 44px;
}
.browse-search { padding: .5rem .7rem; border-bottom: 1px solid var(--line); }
.browse-search input {
appearance: none; width: 100%; min-height: 44px; padding: .5rem .9rem;
border: 1px solid var(--line); border-radius: 999px;
background: var(--card); color: var(--fg); font: inherit;
}
.browse-nav { margin: 0; padding: .7rem 1.1rem 0; }
/* --- the room screen, a second pane in the same sheet --- */
#room h2 { margin: 0; padding: .1rem 1.1rem .45rem; font-size: 1.05rem; letter-spacing: -.01em; }
.claim { margin: 0; padding: 0 1.1rem .35rem; font-weight: 650; }
.claim .sub { display: block; font-weight: 400; color: var(--dim); font-size: .86rem; }
.facts {
display: flex; flex-wrap: wrap; align-items: center; gap: .2rem .9rem;
padding: 0 1.1rem .7rem; color: var(--dim); font-size: .86rem;
border-bottom: 1px solid var(--line);
}
.facts .w { display: flex; align-items: center; gap: .3rem; color: var(--fg); white-space: nowrap; }
.acts { display: flex; flex-wrap: wrap; gap: .5rem; padding: .7rem 1.1rem 0; }
.acts a.bar-btn { text-decoration: none; }
.steps { padding: .2rem 1.1rem 0; }
.steps-ask, .steps-wait, .steps-none, .steps-src { margin: .5rem 0; font-size: .86rem; color: var(--dim); }
.steps-ask .bar-btn { margin-top: .5rem; }
.steps-list { margin: .6rem 0; padding-left: 1.3rem; display: grid; gap: .45rem; }
.steps-list li { font-size: .93rem; line-height: 1.35; }
.steps-m { color: var(--dim); font-variant-numeric: tabular-nums; }
/* The needle turns, the button does not, so the label stays readable while
the arrow points. */
#bearing .ico { transition: transform .12s linear; }
@media (prefers-reduced-motion: reduce) {
#bearing .ico { transition: none; }
}
.unknown {
margin: .5rem 1.1rem 0; padding: .6rem .8rem; border: 1px solid var(--warn);
border-radius: 10px; color: var(--dim); font-size: .84rem;
}
/* --- what the app believes, for a complaint that arrives after the walk --- */
.diag {
margin: .6rem 1.1rem; padding: .7rem .8rem; background: var(--card);
border: 1px solid var(--line); border-radius: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .74rem; line-height: 1.5; color: var(--fg);
white-space: pre-wrap; overflow-x: auto;
user-select: text; -webkit-user-select: text;
}
.diag.picked { outline: 2px solid var(--you); }
.optin {
display: flex; align-items: center; gap: .6rem; padding: .2rem 1.1rem .6rem;
color: var(--dim); font-size: .84rem; min-height: 44px;
}
.optin input { width: 22px; height: 22px; accent-color: var(--accent); }
/* The pill is a flex ROW holding a sentence and a button, and the button was
`flex: none` while the sentence was a bare span. Once the pair wanted more
than 92vw, every pixel of the shrink landed on the sentence: measured at
393px on the off-campus note of the day, since replaced, it rendered 47.3px
wide and 111.3px tall, one word per line. That made the pill 231px tall,
and since it is fixed at z-index 3 over an #ask that had no z-index at all,
it then covered the wordmark, the question and the "1 hour" button, which
could not be tapped at all.
Wrapping fixes the shrink and the height together: the button drops to its
own line rather than squeezing the sentence to a column. Re-measured at
393x852 on the longer sentence the gate prints now: pill 197x126, sentence
167x56 over three lines, nothing over the wordmark. */
/* --- the day, as a calendar --- */
/* The empty parts of this grid are the answer, which is why it replaced a
list of rows that had to write the word "free" on every gap. */
.dnav {
display: flex; align-items: center; justify-content: space-between; gap: .5rem;
padding: .2rem 1.1rem .5rem; color: var(--fg); font-weight: 650; font-size: .92rem;
}
.dstep {
appearance: none; background: none; border: 0; color: var(--dim);
min-width: 44px; min-height: 44px; padding: 0; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.day { display: flex; margin: 0 1.1rem 1rem; position: relative; }
/* The hour column is sized in ch so it fits "noon" and "12 AM" at any root
font size without a magic pixel width. */
.hrs {
list-style: none; margin: 0; padding: 0; position: relative;
flex: none; width: 5.5ch; font-size: .68rem; color: var(--dim);
white-space: nowrap;
}
.hrs li { position: absolute; left: 0; right: 0; transform: translateY(-50%); }
.cols {
position: relative; flex: 1 1 auto; min-width: 0;
border-left: 1px solid var(--line);
/* One hairline per hour, drawn by the background rather than by 14 more
elements. The size is set inline beside the container height. */
background-image: linear-gradient(to bottom, var(--line) 1px, transparent 1px);
background-size: 100% calc(100% / var(--hours, 1));
}
.blk {
position: absolute; left: .35rem; right: 0; overflow: hidden;
background: var(--accent); color: #0b0d10; border-radius: 8px;
padding: .2rem .45rem; font-size: .78rem; line-height: 1.25;
display: flex; flex-direction: column; justify-content: center;
}
.blk b { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.blk span { opacity: .82; font-size: .72rem; }
/* A 30 minute class is 23px tall. Two lines do not fit, so the time goes and
the code stays: which class it is matters more than when, because the grid
already draws when. */
.blk.tight { padding: .05rem .45rem; font-size: .68rem; }
.blk.tight span { display: none; }
.nowline {
position: absolute; left: -3px; right: 0; height: 2px;
background: var(--you); border-radius: 2px;
}
.nowline::before {
content: ''; position: absolute; left: -3px; top: -3px;
width: 8px; height: 8px; border-radius: 50%; background: var(--you);
}
@media (forced-colors: active) {
.blk { background: Highlight; color: HighlightText; forced-color-adjust: none; }
.cols { border-left: 1px solid CanvasText; }
}
#note {
position: fixed; top: calc(var(--safe-t) + .7rem); left: 50%;
transform: translateX(-50%); max-width: min(92vw, 30rem); z-index: 3;
background: var(--card); border: 1px solid var(--line); color: var(--dim);
font-size: .8rem; padding: .5rem .85rem; border-radius: 16px; text-align: center;
display: flex; align-items: center; justify-content: center;
flex-wrap: wrap; gap: .5rem .6rem;
}
#note-text { flex: 1 1 12rem; min-width: 0; }
#note-pick { flex: 0 0 auto; }
/* The question screen carries the same sentence in its own column, as
#ask-where, so the floating pill is redundant there as well as in the way.
The load error used to be the exception; it has the gate card now. */
body.asking #note { display: none !important; }
/* --- what a mouse gets back --- */
/* There was nothing here at all. `grep -c ":hover" index.html` returned 0, so
the only press feedback in the app was `.opt:active { scale(.97) }`, which
is a touch gesture: with a mouse nothing answered until it had already been
clicked. #origin-where is not named because it is a .bar-btn.
All three conditions are load-bearing. hover and pointer keep the
sticky-hover phones out, where a tap leaves a row lit until something else
is tapped. forced-colors keeps this out of high contrast, where the block
at the top of this sheet hands these same controls ButtonFace with
forced-color-adjust:none, and an author colour on top of that is a colour
the reader turned off.
:hover adds no specificity over a class, so .row:hover and .row.on tie at
one element and two classes: this block sits last, and the pairs that
overlap are spelled out rather than left to source order. */
@media (hover: hover) and (pointer: fine) and (forced-colors: none) {
/* :enabled, because the four duration buttons ship disabled and js/app.js
clears it only once boot() resolves. Without the guard a hover lit them
while #ask:not(.ready) .opts was dimming them to .45, which is the
interface promising a control it has not got yet. */
.opt:enabled:hover { background: var(--card-hi); border-color: var(--line-hi); }
.opt.primary:hover { background: var(--accent-hi); border-color: var(--accent-hi); }
/* A row has no background of its own, so its hover is the light it gets,
not a surface swap. Over --bg the .05 white resolves to #17191c, where
--dim measures 7.42:1 and stays over the 7:1 this screen is held to. */
.row:hover { background: rgba(255,255,255,.05); }
.row.on:hover { background: rgba(255,77,61,.12); }
/* The same light, because .b-row and .pick-row are transparent for the same
reason .row is. They are the whole of the nearest-buildings and the picker
screens, so leaving them out meant two screens where nothing answered a
mouse at all. */
.b-row:hover, .pick-row:hover { background: rgba(255,255,255,.05); }
.bar-btn:hover { background: var(--card-hi); border-color: var(--line-hi); }
.dstep:hover { color: var(--fg); }
/* The arrow is already a translucent pill over the map. Hovering makes it
opaque rather than lighter, so it does not read as a second state. */
#back:hover { background: var(--bg); border-color: var(--line-hi); }
}
/* --- the display cutout, in landscape --- */
/* viewport-fit=cover plus black-translucent draws the page under the status
bar, which the top and bottom rows already answer with --safe-t and
--safe-b. Rotate the phone and the cutout moves to a side, where nothing
was reading an inset. These resolve to 0px on every device without one and
in portrait, so they cost nothing anywhere else. */
#ask {
padding-left: max(1.5rem, env(safe-area-inset-left, 0px));
padding-right: max(1.5rem, env(safe-area-inset-right, 0px));
}
/* The third term anchors the arrow to the sheet's content column rather than
to the window corner, so it stays over what it goes back from: at 1900x1000
it sat at x=9.6 with the column starting at x=694, and lands on 694 now.
Below 32rem the term is negative and max() drops it, so on a phone the
arrow does not move: 9.6 at 320, 390 and 430px, before and after. */
#back, #menu { left: max(.6rem, env(safe-area-inset-left, 0px), calc(50% - var(--col) / 2)); }
.m-panel { left: max(.6rem, env(safe-area-inset-left, 0px), calc(50% - var(--col) / 2)); }
#sheet {
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
/* --- the install hint, the refresh notice and the cold-start card --- */
/* Both bars are built in js/install.js and js/pwa.js rather than sitting in
the markup, because neither should exist for a user who never earns it.
They share one rail because both can be up at once: showing the install
hint prefetches the term, and that fetch is what raises the refresh bar.
Each fixed to bottom: 0 they landed on top of each other and the hint's
dismiss X stopped answering taps, measured with elementFromPoint. */
#bars {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 4;
display: flex; flex-direction: column;
padding-bottom: var(--safe-b);
background: var(--card);
box-shadow: 0 -12px 32px rgba(0,0,0,.5);
}
.bar {
display: flex; align-items: center; gap: .7rem;
padding: .7rem 1rem;
padding-left: max(1rem, env(safe-area-inset-left, 0px));
padding-right: max(1rem, env(safe-area-inset-right, 0px));
background: var(--card); border-top: 1px solid var(--line);
animation: bar-up .2s ease-out;
}
@keyframes bar-up { from { transform: translateY(100%); } }
@media (prefers-reduced-motion: reduce) { .bar { animation: none; } }
.bar p { margin: 0; flex: 1 1 auto; font-size: .86rem; line-height: 1.35; }
.bar b { color: var(--fg); font-weight: 700; }
.bar > .ico { width: 1.5em; height: 1.5em; color: var(--accent); }
.bar-go, .bar-x {
appearance: none; font: inherit; cursor: pointer; flex: none;
min-width: 44px; min-height: 44px; border-radius: 999px;
display: flex; align-items: center; justify-content: center;
}
.bar-go { background: var(--accent); border: 1px solid var(--accent); color: #fff; font-weight: 650; padding: 0 1rem; }
.bar-x { background: transparent; border: 1px solid var(--line); color: var(--dim); }
/* The rail stands the sheet on top of it. Padding the sheet instead spends
the answer on the rail, because the box is border-box with a pixel height,
so the pane pays: measured at 375x667 with both bars, #list went 165px to
18px and the ranked list showed no whole room at all. The inset goes with
it, since the rail already carries `padding-bottom: var(--safe-b)` and
counting it twice left a 34.9px dead gap above the rail. */
body.has-bar #sheet { bottom: var(--bar-h, 0px); padding-bottom: 0; }
/* A mouse-and-keyboard desktop gets a split workspace instead of a phone
column enlarged inside the monitor. The map remains the left workspace;
the active list or schedule is a stable panel on the right. */
@media (min-width: 900px) and (hover: hover) and (pointer: fine) {
#sheet {
left: auto; right: 1.5rem; top: 1.5rem; bottom: 1.5rem;
width: min(32rem, 40vw); max-width: none; height: auto !important;