-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtutorial.html
More file actions
4957 lines (4058 loc) · 170 KB
/
Copy pathtutorial.html
File metadata and controls
4957 lines (4058 loc) · 170 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
<title>Chisel & Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+Condensed:wght@600;700&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&family=Silkscreen:wght@400;700&display=swap">
<style>
/* ---------------------------------------------------------------------------
:root carries the complete light palette — Aseprite's classic grey/teal.
Dark redefines tokens only, twice: once for the OS preference, once for an
explicit toggle.
--------------------------------------------------------------------------- */
:root {
--ground: #cfcabb;
--surface: #ded9cc;
--raised: #e9e5da;
--sunken: #bdb8a9;
--well: #c6c1b2;
--bevel-light: #f6f3ec;
--bevel-dark: #8d897c;
--line: #a9a496;
--ink: #1d1b17;
--ink-soft: #46433b;
--muted: #6a665b;
--accent: #a8560a;
--select: #3d6ea8;
--select-ink: #ffffff;
--teal: #3f7f68;
--good: #3f7a2e;
--warn: #9a6b06;
--bad: #a33131;
--code-bg: #bfbaab;
--tok-comment: #6f6a5d;
--tok-string: #3f6f2c;
--tok-key: #9c4d09;
--tok-num: #2c6c80;
--tok-class: #2d5a94;
--tok-fn: #6b3f93;
--measure: 66ch;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #232329;
--surface: #2c2c34;
--raised: #3a3a44;
--sunken: #191a1e;
--well: #202027;
--bevel-light: #54545f;
--bevel-dark: #121216;
--line: #43434e;
--ink: #e9e6e1;
--ink-soft: #c9c5be;
--muted: #948f9c;
--accent: #f2a340;
--select: #4f7bb5;
--select-ink: #ffffff;
--teal: #64a98d;
--good: #7ec86b;
--warn: #e0b155;
--bad: #e06767;
--code-bg: #1b1b21;
--tok-comment: #7f7a88;
--tok-string: #9ac97e;
--tok-key: #f2a340;
--tok-num: #6fc0d4;
--tok-class: #86b4e8;
--tok-fn: #c9a2f0;
}
}
:root[data-theme="dark"] {
--ground: #232329;
--surface: #2c2c34;
--raised: #3a3a44;
--sunken: #191a1e;
--well: #202027;
--bevel-light: #54545f;
--bevel-dark: #121216;
--line: #43434e;
--ink: #e9e6e1;
--ink-soft: #c9c5be;
--muted: #948f9c;
--accent: #f2a340;
--select: #4f7bb5;
--select-ink: #ffffff;
--teal: #64a98d;
--good: #7ec86b;
--warn: #e0b155;
--bad: #e06767;
--code-bg: #1b1b21;
--tok-comment: #7f7a88;
--tok-string: #9ac97e;
--tok-key: #f2a340;
--tok-num: #6fc0d4;
--tok-class: #86b4e8;
--tok-fn: #c9a2f0;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.62;
-webkit-font-smoothing: antialiased;
}
.shell {
max-width: 1220px;
margin: 0 auto;
padding: 0 24px 96px;
display: grid;
grid-template-columns: 1fr;
gap: 0 44px;
}
@media (min-width: 1080px) {
.shell { grid-template-columns: 228px minmax(0, 1fr); }
.rail { display: block; }
}
.rail { display: none; grid-column: 1; }
.rail-inner {
position: sticky;
top: 20px;
padding: 14px 0 24px;
max-height: calc(100vh - 40px);
overflow-y: auto;
}
.rail h2 {
font-family: "Silkscreen", "IBM Plex Mono", monospace;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 10px;
font-weight: 400;
}
.rail ol { list-style: none; margin: 0 0 18px; padding: 0; display: flex; flex-direction: column; gap: 1px; }
.rail a {
display: flex;
gap: 8px;
padding: 3px 6px;
font-size: 13px;
color: var(--ink-soft);
text-decoration: none;
line-height: 1.3;
}
.rail a:hover { background: var(--surface); color: var(--ink); }
.rail a .n {
font-family: "IBM Plex Mono", monospace;
font-size: 10.5px;
color: var(--muted);
padding-top: 2px;
font-variant-numeric: tabular-nums;
}
main { grid-column: 1; min-width: 0; }
@media (min-width: 1080px) { main { grid-column: 2; } }
/* --- masthead ------------------------------------------------------------ */
.masthead { padding: 52px 0 8px; }
.eyebrow {
font-family: "Silkscreen", "IBM Plex Mono", monospace;
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 18px;
display: flex;
align-items: center;
gap: 10px;
}
.eyebrow::after { content: ""; height: 1px; flex: 1; background: var(--line); }
h1 {
font-family: "IBM Plex Sans Condensed", "IBM Plex Sans", sans-serif;
font-weight: 700;
font-size: clamp(38px, 6vw, 62px);
line-height: 1.02;
letter-spacing: -0.015em;
margin: 0 0 18px;
text-wrap: balance;
}
h1 .thin { color: var(--muted); font-weight: 600; }
.standfirst { font-size: 19px; line-height: 1.55; color: var(--ink-soft); max-width: 62ch; margin: 0 0 26px; }
.facts { display: flex; flex-wrap: wrap; border: 1px solid var(--line); background: var(--surface); margin-bottom: 40px; }
.fact { padding: 10px 16px; border-right: 1px solid var(--line); flex: 1 1 auto; min-width: 128px; }
.fact:last-child { border-right: 0; }
.fact dt {
font-family: "IBM Plex Mono", monospace;
font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--muted); margin-bottom: 3px;
}
.fact dd { margin: 0; font-size: 14px; font-weight: 500; }
/* --- prose --------------------------------------------------------------- */
section { padding-top: 52px; scroll-margin-top: 16px; }
h2 {
font-family: "IBM Plex Sans Condensed", sans-serif;
font-size: clamp(25px, 3.2vw, 33px);
line-height: 1.12;
margin: 0 0 8px;
text-wrap: balance;
}
h2 .num {
font-family: "Silkscreen", monospace;
font-size: 12px; color: var(--accent);
display: block; letter-spacing: 0.14em; margin-bottom: 10px; font-weight: 400;
}
h3 { font-family: "IBM Plex Sans Condensed", sans-serif; font-size: 20px; margin: 34px 0 8px; }
h4 {
font-size: 12.5px; font-family: "IBM Plex Mono", monospace;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--muted); margin: 26px 0 8px; font-weight: 500;
}
p, ul, ol { max-width: var(--measure); }
p { margin: 0 0 16px; }
ul, ol { margin: 0 0 16px; padding-left: 20px; }
li { margin-bottom: 6px; }
li::marker { color: var(--muted); }
a { color: var(--accent); text-underline-offset: 3px; }
a:focus-visible { outline: 2px solid var(--select); outline-offset: 2px; }
code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.875em;
background: var(--well);
border: 1px solid var(--line);
padding: 0 4px;
white-space: nowrap;
}
.lede { font-size: 17.5px; color: var(--ink-soft); max-width: 62ch; }
/* --- code ---------------------------------------------------------------- */
figure.code { margin: 20px 0 24px; max-width: 920px; }
figure.code figcaption {
font-family: "IBM Plex Mono", monospace;
font-size: 11.5px;
color: var(--ink-soft);
background: var(--raised);
border: 1px solid var(--line);
border-bottom: 0;
padding: 5px 10px;
display: flex; justify-content: space-between; gap: 12px; align-items: center;
box-shadow: inset 1px 1px 0 var(--bevel-light);
}
figure.code figcaption .role { color: var(--muted); }
.chip {
font-family: "Silkscreen", monospace;
font-size: 9px; letter-spacing: 0.06em;
padding: 2px 5px; border: 1px solid var(--line);
color: var(--muted); background: var(--surface);
}
.chip.new { color: var(--good); border-color: var(--good); }
.chip.edit { color: var(--warn); border-color: var(--warn); }
pre {
margin: 0;
background: var(--code-bg);
border: 1px solid var(--line);
box-shadow: inset 1px 1px 0 var(--bevel-dark);
padding: 13px 15px;
overflow-x: auto;
font-size: 13.5px;
line-height: 1.6;
tab-size: 2;
}
pre code { background: none; border: 0; padding: 0; font-size: inherit; white-space: pre; color: var(--ink); }
.t-c { color: var(--tok-comment); }
.t-s { color: var(--tok-string); }
.t-k { color: var(--tok-key); font-weight: 500; }
.t-n { color: var(--tok-num); }
.t-t { color: var(--tok-class); }
.t-f { color: var(--tok-fn); }
/* --- run-it checkpoints -------------------------------------------------- */
.runit {
max-width: 920px;
margin: 26px 0;
border: 1px solid var(--good);
background: var(--surface);
}
.runit .head {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px;
background: color-mix(in srgb, var(--good) 14%, var(--surface));
border-bottom: 1px solid var(--good);
font-family: "Silkscreen", monospace;
font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--good);
}
.runit .body { padding: 12px 14px; }
.runit .body p:last-child { margin-bottom: 0; }
.runit .body p, .runit .body ul { max-width: none; }
.runit pre { margin: 0 0 10px; }
/* --- file tree ----------------------------------------------------------- */
.tree {
max-width: 920px;
background: var(--surface);
border: 1px solid var(--line);
padding: 14px 16px;
font-family: "IBM Plex Mono", monospace;
font-size: 12.5px;
line-height: 1.7;
overflow-x: auto;
margin: 20px 0 26px;
white-space: pre;
color: var(--ink-soft);
}
.tree b { color: var(--ink); font-weight: 600; }
.tree i { color: var(--muted); font-style: normal; }
/* --- callouts ------------------------------------------------------------ */
.note {
max-width: 920px; margin: 22px 0;
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
background: var(--surface);
padding: 13px 17px;
}
.note.warn { border-left-color: var(--bad); }
.note.why { border-left-color: var(--select); }
.note h4 { margin: 0 0 6px; color: var(--ink); }
.note p:last-child, .note ul:last-child { margin-bottom: 0; }
.note p, .note ul { max-width: none; }
/* --- tables -------------------------------------------------------------- */
.tablewrap { overflow-x: auto; margin: 20px 0 24px; max-width: 920px; border: 1px solid var(--line); }
table { border-collapse: collapse; width: 100%; font-size: 14px; background: var(--surface); }
th, td { text-align: left; padding: 7px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
th {
background: var(--raised);
font-family: "IBM Plex Mono", monospace;
font-size: 11px; letter-spacing: 0.07em; text-transform: uppercase;
color: var(--muted); font-weight: 500;
}
tr:last-child td { border-bottom: 0; }
td code { white-space: nowrap; }
.sw { display: inline-block; width: 32px; height: 13px; border: 1px solid var(--bevel-dark); vertical-align: -2px; }
/* --- papercuts ----------------------------------------------------------- */
.cut {
max-width: 920px;
border: 1px solid var(--line);
border-left: 3px solid var(--muted);
background: var(--surface);
padding: 12px 16px;
margin: 0 0 10px;
}
.cut.sev-high { border-left-color: var(--bad); }
.cut.sev-mid { border-left-color: var(--warn); }
.cut.sev-low { border-left-color: var(--muted); }
.cut .title { display: flex; flex-wrap: wrap; gap: 10px; align-items: baseline; margin-bottom: 4px; }
.cut .title strong { font-size: 15px; }
.cut .tag {
font-family: "IBM Plex Mono", monospace; font-size: 10px;
letter-spacing: 0.06em; text-transform: uppercase;
border: 1px solid currentColor; padding: 1px 5px;
}
.cut.sev-high .tag { color: var(--bad); }
.cut.sev-mid .tag { color: var(--warn); }
.cut.sev-low .tag { color: var(--muted); }
.cut p { margin: 0 0 6px; max-width: none; font-size: 14.5px; }
.cut p:last-child { margin-bottom: 0; }
.cut .fix b { font-family: "IBM Plex Mono", monospace; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); font-weight: 500; }
.verified {
display: inline-flex; align-items: center; gap: 8px;
font-family: "IBM Plex Mono", monospace; font-size: 11.5px;
color: var(--good); border: 1px solid var(--good);
padding: 4px 9px; margin-bottom: 18px;
}
/* --- workspace mock ------------------------------------------------------
Catppuccin Mocha, hard-coded, because this depicts one specific product
rather than participating in this page's own light/dark theming. Flat fills
and single-pixel rules only: there is not an inset highlight anywhere below,
which is the whole difference between this and the interface it replaced. */
.mock {
--m-base: #1e1e2e; --m-mantle: #181825;
--m-s0: #313244; --m-s1: #45475a; --m-s2: #585b70;
--m-text: #cdd6f4; --m-dim: #7f849c; --m-accent: #89b4fa;
border: 1px solid var(--m-mantle);
background: var(--m-base);
box-shadow: 0 0 0 1px var(--line);
font-family: "IBM Plex Mono", monospace;
font-size: 10px; color: var(--m-dim);
user-select: none; max-width: 920px; margin: 0 0 12px;
}
.mock .bar {
display: flex; align-items: center; gap: 14px;
padding: 3px 8px; background: var(--m-s0); color: var(--m-text);
}
.mock .bar.title { background: var(--m-s1); color: var(--m-text); font-weight: 600; letter-spacing: 0.05em; }
.mock .tab { padding: 2px 12px; background: var(--m-s0); color: var(--m-dim); }
.mock .tab.on { background: var(--m-s2); color: var(--m-text); }
.mock .body { display: grid; grid-template-columns: 68px minmax(0,1fr) 30px; }
.mock .col { background: var(--m-s0); padding: 6px; display: flex; flex-direction: column; gap: 6px; }
.mock .col.right { align-items: center; gap: 3px; padding: 5px 3px; }
.mock .swatches { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2px; }
.mock .swatches i { display: block; aspect-ratio: 1; border: 1px solid var(--m-mantle); }
/* A tool button carries no fill at all until it is selected - which is what
Aseprite's toolbars actually do. */
.mock .btn {
width: 20px; height: 20px; background: transparent;
display: grid; place-items: center; font-size: 9px; color: var(--m-text);
}
.mock .btn.on { background: var(--m-s2); }
.mock .canvas {
min-height: 186px;
background: repeating-conic-gradient(#808080 0% 25%, #c0c0c0 0% 50%) 0 0 / 12px 12px;
display: grid; place-items: center; padding: 18px;
}
.mock .sprite { width: 108px; height: 108px; border: 1px solid var(--m-mantle); background: var(--m-accent); position: relative; }
.mock .sprite::after {
content: ""; position: absolute; inset: 0;
background-image:
linear-gradient(to right, rgba(0,0,0,.14) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0,0,0,.14) 1px, transparent 1px);
background-size: 12px 12px;
}
.mock .timeline { background: var(--m-s0); padding: 4px 6px; display: flex; flex-direction: column; gap: 3px; }
.mock .frames { display: flex; gap: 2px; }
.mock .frames span { width: 18px; height: 12px; background: var(--m-mantle); }
.mock .frames span.on { background: var(--m-s2); }
.mock .status {
display: flex; justify-content: space-between;
background: var(--m-s0); color: var(--m-dim); padding: 3px 8px;
}
.mock-legend {
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 3px 20px; max-width: 920px; font-size: 12.5px; color: var(--muted); margin-bottom: 8px;
}
.mock-legend b { color: var(--ink-soft); font-weight: 600; font-family: "IBM Plex Mono", monospace; font-size: 11px; }
figure.fig { margin: 0 0 28px; max-width: 920px; }
figure.fig figcaption { font-size: 13px; color: var(--muted); margin-top: 10px; max-width: 66ch; }
/* --- state matrix -------------------------------------------------------- */
.states { display: flex; flex-wrap: wrap; gap: 16px; margin: 20px 0 8px; }
.state { text-align: center; }
.state .swatchbtn {
width: 74px; height: 30px; display: grid; place-items: center;
font-family: "IBM Plex Mono", monospace; font-size: 11px;
background: var(--raised); border: 1px solid var(--bevel-dark);
box-shadow: inset 1px 1px 0 var(--bevel-light); color: var(--ink);
}
.state .swatchbtn.hover { background: color-mix(in srgb, var(--raised) 82%, var(--bevel-light)); }
.state .swatchbtn.press { box-shadow: inset 1px 1px 0 var(--bevel-dark); background: var(--well); }
.state .swatchbtn.sel { background: var(--select); color: var(--select-ink); box-shadow: inset 1px 1px 0 rgba(255,255,255,.3); }
.state .swatchbtn.dis { color: var(--muted); background: var(--surface); box-shadow: inset 1px 1px 0 var(--bevel-light); }
.state small { display: block; margin-top: 6px; font-size: 11px; color: var(--muted); font-family: "IBM Plex Mono", monospace; }
hr.rule { border: 0; border-top: 1px solid var(--line); margin: 46px 0 0; max-width: 920px; }
footer { margin-top: 68px; padding-top: 20px; border-top: 1px solid var(--line); font-size: 13.5px; color: var(--muted); max-width: 920px; }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
</style>
<div class="shell">
<nav class="rail" aria-label="Contents">
<div class="rail-inner">
<h2>Build order</h2>
<ol>
<li><a href="#p0"><span class="n">00</span><span>Setup, and a window</span></a></li>
<li><a href="#p1"><span class="n">01</span><span>What a GUI is</span></a></li>
<li><a href="#p2"><span class="n">02</span><span>Rect</span></a></li>
<li><a href="#p3"><span class="n">03</span><span>Theme</span></a></li>
<li><a href="#p4"><span class="n">04</span><span>Painter</span></a></li>
<li><a href="#p5"><span class="n">05</span><span>Traits</span></a></li>
<li><a href="#p6"><span class="n">06</span><span>Widget</span></a></li>
<li><a href="#p7"><span class="n">07</span><span>Ui and Pointer</span></a></li>
<li><a href="#p8"><span class="n">08</span><span>Button</span></a></li>
<li><a href="#p9"><span class="n">09</span><span>The playground</span></a></li>
<li><a href="#p10"><span class="n">10</span><span>Dock</span></a></li>
<li><a href="#p11"><span class="n">11</span><span>Studio</span></a></li>
<li><a href="#p12"><span class="n">12</span><span>Commands, keymap</span></a></li>
<li><a href="#p13"><span class="n">13</span><span>Editor chrome</span></a></li>
<li><a href="#p14"><span class="n">14</span><span>Document, viewport</span></a></li>
<li><a href="#p15"><span class="n">15</span><span>The map editor</span></a></li>
<li><a href="#p16"><span class="n">16</span><span>Room for sound</span></a></li>
<li><a href="#p17"><span class="n">17</span><span>Ship it</span></a></li>
</ol>
<h2>Reference</h2>
<ol>
<li><a href="#papercuts"><span class="n">··</span><span>Papercut log</span></a></li>
<li><a href="#cheatsheet"><span class="n">··</span><span>Cheat sheet</span></a></li>
</ol>
</div>
</nav>
<main>
<header class="masthead">
<p class="eyebrow">Build it yourself · Lumen + Ghost</p>
<h1>Chisel <span class="thin">&</span> Studio</h1>
<p class="standfirst">
Type out a GUI framework and an editor shell for Lumen, file by file, from first principles.
Aseprite's look, Ghost's grain, and one toolkit that hosts a pixel editor, a map editor and —
eventually — a sound editor.
</p>
<p class="standfirst">
The interface is measured rather than invented: every metric comes off a real Aseprite
screenshot, every colour off the published Catppuccin palette, and a pixel-comparison tool in
the repository holds the result against the references on every commit.
</p>
<dl class="facts">
<div class="fact"><dt>Layers</dt><dd>chisel → studio → editors</dd></div>
<div class="fact"><dt>Style</dt><dd>Retained tree, immediate paint</dd></div>
<div class="fact"><dt>Rule</dt><dd>One class per file</dd></div>
<div class="fact"><dt>Dependencies</dt><dd>None</dd></div>
</dl>
</header>
<figure class="fig">
<div class="mock" role="img" aria-label="Mock of the finished workspace: title bar, menu bar, document tabs, context bar, colour bar on the left, tool bar on the right, checkerboard canvas, timeline and status bar.">
<div class="bar title">Sprite-0001.ase — Studio</div>
<div class="bar"><span>File</span><span>Edit</span><span>Sprite</span><span>Layer</span><span>Frame</span><span>Select</span><span>View</span><span>Help</span></div>
<div class="bar" style="gap:0"><span class="tab on">Sprite-0001</span><span class="tab">overworld.map</span></div>
<div class="bar"><span>Pixel-perfect</span><span>Alpha</span><span>Opacity 255</span><span>Size 1</span></div>
<div class="body">
<div class="col">
<div class="swatches">
<i style="background:#000000"></i><i style="background:#1d2b53"></i><i style="background:#7e2553"></i><i style="background:#008751"></i>
<i style="background:#ab5236"></i><i style="background:#5f574f"></i><i style="background:#c2c3c7"></i><i style="background:#fff1e8"></i>
<i style="background:#ff004d"></i><i style="background:#ffa300"></i><i style="background:#ffec27"></i><i style="background:#00e436"></i>
<i style="background:#29adff"></i><i style="background:#83769c"></i><i style="background:#ff77a8"></i><i style="background:#ffccaa"></i>
</div>
<div style="display:flex;gap:4px;align-items:center">
<i style="width:20px;height:20px;background:#ff004d;border:1px solid var(--bevel-dark)"></i>
<i style="width:20px;height:20px;background:#fff1e8;border:1px solid var(--bevel-dark)"></i>
</div>
</div>
<div style="display:flex;flex-direction:column">
<div class="canvas"><div class="sprite"></div></div>
<div class="timeline">
<div class="frames"><span class="on"></span><span></span><span></span><span></span><span></span><span></span></div>
<div style="display:flex;gap:6px;align-items:center"><span>◉ ⌂</span><span>Layer 1</span></div>
<div style="display:flex;gap:6px;align-items:center;color:var(--muted)"><span>◉ ⌂</span><span>Background</span></div>
</div>
</div>
<div class="col right">
<div class="btn on">✎</div><div class="btn">▤</div><div class="btn">◻</div><div class="btn">◯</div>
<div class="btn">⬒</div><div class="btn">✧</div><div class="btn">⌖</div><div class="btn">T</div>
</div>
</div>
<div class="status"><span>Sprite-0001 · 32 × 32</span><span>Frame 1 / 6 · 500 ms</span></div>
</div>
<div class="mock-legend">
<div><b>menu bar</b> dock.top</div>
<div><b>tabs</b> dock.top</div>
<div><b>context bar</b> dock.top</div>
<div><b>colour bar</b> dock.left</div>
<div><b>tool bar</b> dock.right</div>
<div><b>timeline</b> dock.bottom</div>
<div><b>status bar</b> dock.bottom</div>
<div><b>viewport</b> dock.fill</div>
</div>
<figcaption>
Where this ends up. You will have typed every pixel of it yourself by part 13, and the same
shell opens a map document in part 14.
</figcaption>
</figure>
<p class="lede">
This is a hands-on build, not a tour. Each part adds one or two files, explains why they exist
before showing what they contain, and ends — often — with something you can run. Nothing is
imported before it is written, so if you type along in order, the program runs at every step.
</p>
<p>
Two things up front. <strong>Every language behaviour claimed here was executed against a real
Ghost build</strong> rather than inferred from documentation — several of Ghost's rules are
surprising, one of them badly, and the architecture below is shaped around what the interpreter
actually does. And <strong>the architecture is deliberately un-clever</strong>: no service
container, no dependency injection, no plugin discovery. Explicit imports and constructor
arguments, because in a language with no reflection, indirection buys nothing and costs you every
"where does this come from?" question.
</p>
<section id="p0">
<h2><span class="num">Part 00</span>Setup, and a window on screen</h2>
<p>
Download the Lumen binary from <a href="https://www.ghostlang.org/download">ghostlang.org/download</a>
and put it on your <code>PATH</code>. Nothing to compile: the engine, the Ghost runtime and a
built-in font are all inside that one file.
</p>
<p>
A Lumen game — or in our case an application — is a folder with a <code>main.gs</code> in it.
Make the folder and the entry file:
</p>
<figure class="code">
<figcaption><span>terminal</span><span class="role">start here</span></figcaption>
<pre><code class="sh">mkdir studio && cd studio
mkdir -p chisel/support chisel/geometry chisel/traits chisel/themes chisel/widgets chisel/layout
mkdir -p studio/traits studio/sprite/tools
touch main.gs</code></pre>
</figure>
<p>
Three folders, three jobs, and a rule that never bends: <strong>chisel knows nothing about
editors, studio knows nothing about sprites.</strong> Dependencies point one way only.
</p>
<div class="tree"><b>studio/</b>
├── <b>main.gs</b> <i>the entry file Lumen looks for</i>
├── <b>chisel/</b> <i>the GUI framework — widgets, layout, painting, input</i>
├── <b>studio/</b> <i>the editor shell — workspace, commands, registries</i>
│ ├── <b>sprite/</b> <i>the pixel editor</i>
│ ├── <b>map/</b> <i>the map editor</i>
│ └── <b>sound/</b> <i>the sound editor, later</i>
└── <b>resources/</b> <i>fonts, icons</i></div>
<p>
One file before the entry point, because the very first line of <code>load()</code> needs it.
This is the whole of the framebuffer decision, and it is nine lines:
</p>
<figure class="code">
<figcaption><span>chisel/support/logical-size.gs</span><span class="chip new">new</span></figcaption>
<pre><code class="gs">import "ghost:math"
// The framebuffer to draw into, given a real window.
//
// `target` is the logical height the design was drawn for - 540 for Aseprite's
// chrome. Returns the integer magnification that puts the window nearest that,
// and the window divided by it.
function logicalSize(width, height, preferred = null, target = 540) {
scale = preferred
if (scale == null) {
scale = math.floor((height / (target * 1.0)) + 0.5)
}
scale = math.floor(scale)
if (scale < 1) {
scale = 1
}
// A window smaller than one magnification would otherwise divide to nothing.
return {
w: math.max(1, math.floor(width / scale)),
h: math.max(1, math.floor(height / scale)),
scale: scale
}
}</code></pre>
</figure>
<p>
Lumen calls <code>load()</code> once, then <code>update(dt)</code> and <code>draw()</code> every
frame, and hands input to optional callbacks. That is the entire contract. Type this:
</p>
<figure class="code">
<figcaption><span>main.gs</span><span class="chip new">new</span></figcaption>
<pre><code class="gs">import "lumen:window"
import "lumen:canvas"
import "lumen:color"
import { logicalSize } from "chisel/support/logical-size"
function load() {
window.setTitle('Studio')
window.setMode(1440, 900)
window.setResizable(true)
window.setVsync(true)
// Draw into a small framebuffer and let the engine magnify the whole frame.
// A 12px menu bar and a 16x16 icon only read correctly if one drawn pixel
// covers several screen pixels; at the window's own resolution they are not
// chunky, they are microscopic.
frame = logicalSize(1440, 900)
window.setLogicalSize(frame.w, frame.h)
window.setPixelPerfect(true)
}
function draw() {
canvas.clear(color.hex('#1e1e2e'))
}</code></pre>
</figure>
<div class="callout">
<p>
<strong>That framebuffer is the decision everything else rests on</strong>, so it is worth being
precise about now rather than discovering later. Aseprite's chrome is drawn for a 960×540 grid
and magnified twice on screen. Its measurements — a 12px menu bar, an 11px tab, 15px tool slots
— are meaningless anywhere else: at 1× on a modern display they are a few millimetres tall.
</p>
<p>
The magnification is picked rather than fixed. <code>logicalSize()</code> takes the height the
design was drawn for, finds the integer magnification that puts the window nearest it, and
divides. 1920×1080 gives exactly 960×540 at 2×; 1440×900 gives 720×450 at the same 2×. A wider
monitor therefore buys <em>workspace</em> rather than bigger chrome, which matters because this
is an editor inside a window rather than an operating system that owns the screen.
</p>
<p>
Whole numbers only, and this is the one rule that cannot bend: a fractional magnification draws
some source pixels two screen pixels wide and their neighbours three. That is precisely how
pixel art comes out looking uneven, and a program for making pixel art cannot be the thing that
does it.
</p>
</div>
<div class="runit">
<div class="head">▶ Run it</div>
<div class="body">
<pre><code class="sh">lumen .</code></pre>
<p>
A resizable 1280×800 window filled with dark grey. If Lumen complains it cannot find a game,
you are one directory up — it looks for <code>main.gs</code> in the folder you point it at.
</p>
</div>
</div>
<div class="note warn">
<h4>Name the entry file <code>main.gs</code></h4>
<p>
Passing a filename explicitly (<code>lumen main.ghost</code>) works with any extension, but
running a <em>folder</em> looks for <code>main.gs</code> exactly. And Ghost resolves
<code>import "chisel/painter"</code> to <code>chisel/painter.gs</code>, so every file in this
tutorial ends in <code>.gs</code>.
</p>
</div>
</section>
<hr class="rule">
<section id="p1">
<h2><span class="num">Part 01</span>What a GUI actually is</h2>
<p>
Strip away the widgets and a graphical interface answers four questions, once per frame:
</p>
<ol>
<li><strong>Where is everything?</strong> Given a window of some size, decide the rectangle every
element occupies. This is <em>layout</em>.</li>
<li><strong>What does it look like?</strong> Given those rectangles and some state, put pixels on
screen. This is <em>paint</em>.</li>
<li><strong>Who gets the click?</strong> Given a point, find the element under it. This is
<em>hit testing</em>.</li>
<li><strong>Who owns the drag?</strong> Once a press has started, one element keeps receiving
movement until the button comes up — even outside its own bounds. This is <em>capture</em>, and
it is the question toy GUIs forget.</li>
</ol>
<p>
There are two classic ways to arrange those answers. <strong>Immediate mode</strong> asks all four
inside the draw call: <code>if (button('Pencil')) { ... }</code>. Nothing is stored, everything is
recomputed, and layout has to be re-derived every frame from scratch. <strong>Retained mode</strong>
builds a tree of objects up front and mutates it; the framework decides when to repaint.
</p>
<p>
Chisel takes the half of each that suits an editor:
</p>
<div class="note why">
<h4>Retained tree, immediate paint</h4>
<p>
<strong>The tree is retained</strong> — you declare the workspace once, fluently, and keep the
objects. An editor's interface has a fixed <em>shape</em>: a tool bar is always a tool bar.
Declaring it once means layout runs only when the window resizes, and a button can carry its
own state (selected, disabled, its tooltip) without you threading it through a draw call.
</p>
<p>
<strong>Painting is immediate</strong> — <code>paint()</code> reads the world and draws it, every
frame, with no cached render state and no invalidation bookkeeping. At 1280×800 the chrome costs
a few hundred rectangles; that is nothing. The one thing expensive enough to cache is the
document itself, and that gets an explicit dirty flag in part 13.
</p>
</div>
<p>
So the frame looks like this, and it never gets more complicated than this:
</p>
<figure class="code">
<figcaption><span>the frame, in pseudocode</span><span class="role">for orientation only</span></figcaption>
<pre><code class="sh">load() → build the widget tree once, place it into the window rect
resize() → re-place the root; every container re-divides its area
update(dt) → tick animating widgets; age the hover timer
draw() → clear, walk the tree painting, then draw overlays on top
mouse → pick the deepest widget under the point, or route to whoever captured</code></pre>
</figure>
<h3>The four objects everything else is made of</h3>
<p>
Before any widget exists we need four things, and they are the next four parts:
a <strong>Rect</strong> (where), a <strong>Theme</strong> (what colour), a
<strong>Painter</strong> (how to draw a surface), and a <strong>Widget</strong> (a node in the tree).
Then a <strong>Ui</strong> to own the tree and the input state. Six files and you have a working
toolkit; everything after is widgets.
</p>
<div class="note">
<h4>Two conventions, applied from here on</h4>
<ul>
<li><strong>One class per file, one trait per file, one function per helper file</strong>, named
after what is inside. Free-standing functions exist only in <code>support/</code> and in
<code>main.gs</code>'s engine callbacks.</li>
<li><strong>State lives in objects, never in module variables.</strong> This is not taste — a
Ghost function cannot assign to a variable outside itself (part 03 shows the trap). Instance
fields are the only reliable mutable state in the language.</li>
</ul>
</div>
</section>
<hr class="rule">
<section id="p2">
<h2><span class="num">Part 02</span>Rect — the unit of layout</h2>
<p>
Everything in a GUI is a rectangle being divided into smaller rectangles. Get this class right and
the layout code that follows is four lines per container; get it wrong and you will be adding and
subtracting stray pixels for the rest of the build.
</p>
<p>
It has one job beyond arithmetic: <strong>every rectangle is integral</strong>. Ghost's
<code>/</code> always produces a float — <code>20 / 2</code> is a float — and a one-pixel line at
a fractional y is a two-pixel smear. Rather than remembering to round at every call site, the
constructor rounds and the invariant holds everywhere.
</p>
<figure class="code">
<figcaption><span>chisel/support/snap.gs</span><span class="chip new">new</span></figcaption>
<pre><code class="gs">import "ghost:math"
// snap rounds to the nearest whole pixel. Flooring alone would bias every
// centred thing half a pixel up and left, which shows up as a one-pixel gap
// down the right edge of every other panel.
function snap(value) {
return math.floor(value + 0.5)
}</code></pre>
</figure>
<figure class="code">
<figcaption><span>chisel/geometry/rect.gs</span><span class="chip new">new</span></figcaption>
<pre><code class="gs">import "ghost:math"
import { snap } from "chisel/support/snap"
// A rectangle in window pixels. Always integral: the constructor snaps, so
// nothing downstream has to remember to.
class Rect {
constructor(x, y, width, height) {
this.x = snap(x)
this.y = snap(y)
this.w = snap(math.max(0, width))
this.h = snap(math.max(0, height))
}
right() { return this.x + this.w }
bottom() { return this.y + this.h }
isEmpty() { return this.w == 0 or this.h == 0 }
contains(x, y) {
return x >= this.x and y >= this.y and x < this.right() and y < this.bottom()
}
// inset shrinks by the same amount on every side — padding, in one call.
inset(amount) {
return new Rect(this.x + amount, this.y + amount, this.w - amount * 2, this.h - amount * 2)
}
offset(dx, dy) {
return new Rect(this.x + dx, this.y + dy, this.w, this.h)
}
// The split family is the whole of layout. Each answers [taken, rest], so a
// container carves its area down and hands each piece to a child:
//
// [bar, rest] = area.splitTop(20)
//
splitTop(size) {
size = math.min(size, this.h)
return [
new Rect(this.x, this.y, this.w, size),
new Rect(this.x, this.y + size, this.w, this.h - size)
]
}
splitBottom(size) {
size = math.min(size, this.h)
return [
new Rect(this.x, this.bottom() - size, this.w, size),
new Rect(this.x, this.y, this.w, this.h - size)
]
}
splitLeft(size) {
size = math.min(size, this.w)
return [
new Rect(this.x, this.y, size, this.h),
new Rect(this.x + size, this.y, this.w - size, this.h)
]
}
splitRight(size) {
size = math.min(size, this.w)
return [
new Rect(this.right() - size, this.y, size, this.h),
new Rect(this.x, this.y, this.w - size, this.h)
]
}
// A grid of cells, used by the palette, the timeline and the tile picker.
// Cells are placed on exact pixel steps rather than by dividing the width,
// so no row ever drifts a pixel wider than another.
cell(column, row, size, gap) {
step = size + gap
return new Rect(this.x + column * step, this.y + row * step, size, size)
}
equals(other) {
return this.x == other.x and this.y == other.y and this.w == other.w and this.h == other.h
}
toString() {
return `[${this.x},${this.y} ${this.w}x${this.h}]`
}
}</code></pre>
</figure>
<div class="note why">
<h4>Why <code>splitTop</code> returns a list of two</h4>
<p>
Ghost has destructuring assignment, so <code>[bar, rest] = area.splitTop(20)</code> reads exactly
like the sentence "take twenty off the top." The alternative — a container mutating a running
<code>area</code> field — would work too, but this version has no state at all, which means the
dock in part 09 can be re-run at any time on any rectangle and always produce the same layout.
That is what makes window resizing free.
</p>
</div>
<div class="runit">
<div class="head">▶ Run it</div>
<div class="body">
<p>Replace <code>draw()</code> in <code>main.gs</code> to prove the geometry before it matters:</p>
<pre><code class="gs">import { Rect } from "chisel/geometry/rect"
function draw() {
// That semicolon is not optional — see the note below.
canvas.clear(color.hex('#1e1e2e'));
[bar, rest] = new Rect(0, 0, window.width, window.height).splitTop(40)
canvas.setColor(color.hex('#313244'))