The atlas row set its type off the card's width and then placed that type at fixed offsets: the name at 16, the numbers at 31, the difficulty marks at 36, the meter thirteen units up from the bottom. Those constants describe a 72-unit card, and a card is only 72 units tall at one size. Its height is clamped — between 46 and 72 in two columns, never under 52 in one — while its width follows the screen and is clamped by nothing. So a wide short card asked for four lines cut for a tall one and printed them on top of each other: at 400x700 the meter ran straight through the marks, and at 1280x430 it ran through the numbers as well, with STEPS and STAGE meeting in the middle from opposite ends of the same line. Every one of those offsets now follows from the card. The four lines each claim the height of the type or the marks they are made of, what is left over is the air between them shared out evenly and then capped, and the whole stack is centred. The marks keep their slot on a card that has none, so a played set and an unplayed one still line their names up. The type takes the smaller of what the width allows and what the height can hold. The meter is what its caption leaves after measuring it, rather than a fixed seventy-two units that AT THE CEILING never fitted beside; the marks and the end stops are cut from the same unit as the type; and the two halves of the numbers line are measured against each other instead of both running to the full column. The faces change with it. Tiny5 for display and DM Mono for everything read rather than announced, which is why Face is now Display and Text rather than two weights of one family: Tiny5 ships a single weight, and a bitmap face at fourteen pixels cannot hold a bold convincingly anyway. Tiny5 is cut on the same eight-pixel em Silkscreen was and with the same five-pixel capital, so nothing that was placed by a cap height moved. Cap height is now something typeface.gleam knows rather than something the painters assume, since that is the measurement a stack of capitals is actually built on.
- commit
- d4072597f09131396c507224285e8ec10ab0a444
- parent
- 9c4a2ddfcc250b3f6771b877b3b5773ac4166d92
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 11 | +- |
| A | assets/ | 93 | ++++++++++ |
| A | assets/ | 93 | ++++++++++ |
| A | assets/ | bin | |
| D | assets/ | bin | |
| D | assets/ | bin | |
| D | assets/ | bin | |
| A | assets/ | bin | |
| M | assets/ | 16 | +- |
| M | assets/ | 7 | +- |
| M | src/ | 4 | +- |
| M | src/ | 5 | +- |
| M | src/ | 283 | ++++++++++++++++++++---------- |
| M | src/ | 4 | +- |
| M | src/ | 18 | +- |
| M | src/ | 34 | +++- |
| M | src/ | 9 | +- |
@@ -414,7 +414,10 @@ the card before you start, so neither is a hint.
414414 415415 **Type is not art and is not dithered.** It is drawn on a second canvas at full device resolution416416 in flat ink. Thresholding a typeface turns it to gravel, which is survivable for a title and417-unreadable for anything else. Silkscreen for display, Space Mono for labels.417+unreadable for anything else. Tiny5 for display, DM Mono for labels. Tiny5 ships one weight, so a418+title is not a heavier caption but a different kind of letter — which is the stronger signal419+anyway: a bitmap face at fourteen pixels cannot hold a bold convincingly, and the two it does hold420+end up looking like the same word printed twice.418421 419422 **What the system already knows, it is not asked again.** Two questions have been answered420423 somewhere else and are read live rather than at boot, so a phone that goes dark at sunset does not@@ -795,8 +798,10 @@ concentric rings by distance, which is O(V) and legible where a force-directed l
795798 796799 ## Credits797800 798-Silkscreen (Jason Kottke) and Space Mono, both under the SIL Open Font License — see799-`mockup/fonts/`. Subset to Latin-1 and served locally; nothing is fetched at runtime.801+Tiny5 (Stefan Schmidt) and DM Mono (Colophon Foundry), both under the SIL Open Font License — see802+`assets/fonts/`. Subset to Latin-1 and served locally; nothing is fetched at runtime. The mockup is803+locked and keeps the pair it was drawn with, Silkscreen (Jason Kottke) and Space Mono, under the804+same licence in `mockup/fonts/`.800805 801806 An earlier pass used IM Fell DW Pica, the face *Return of the Obra Dinn* sets its text in, with a802807 screen-space noise background. Both were abandoned: on a phone the thresholded serif was gravel at@@ -0,0 +1,93 @@
1+Copyright 2020 The DM Mono Project Authors (https://www.github.com/googlefonts/dm-mono)
2+
3+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+This license is copied below, and is also available with a FAQ at:
5+http://scripts.sil.org/OFL
6+
7+
8+-----------------------------------------------------------
9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10+-----------------------------------------------------------
11+
12+PREAMBLE
13+The goals of the Open Font License (OFL) are to stimulate worldwide
14+development of collaborative font projects, to support the font creation
15+efforts of academic and linguistic communities, and to provide a free and
16+open framework in which fonts may be shared and improved in partnership
17+with others.
18+
19+The OFL allows the licensed fonts to be used, studied, modified and
20+redistributed freely as long as they are not sold by themselves. The
21+fonts, including any derivative works, can be bundled, embedded,
22+redistributed and/or sold with any software provided that any reserved
23+names are not used by derivative works. The fonts and derivatives,
24+however, cannot be released under any other type of license. The
25+requirement for fonts to remain under this license does not apply
26+to any document created using the fonts or their derivatives.
27+
28+DEFINITIONS
29+"Font Software" refers to the set of files released by the Copyright
30+Holder(s) under this license and clearly marked as such. This may
31+include source files, build scripts and documentation.
32+
33+"Reserved Font Name" refers to any names specified as such after the
34+copyright statement(s).
35+
36+"Original Version" refers to the collection of Font Software components as
37+distributed by the Copyright Holder(s).
38+
39+"Modified Version" refers to any derivative made by adding to, deleting,
40+or substituting -- in part or in whole -- any of the components of the
41+Original Version, by changing formats or by porting the Font Software to a
42+new environment.
43+
44+"Author" refers to any designer, engineer, programmer, technical
45+writer or other person who contributed to the Font Software.
46+
47+PERMISSION & CONDITIONS
48+Permission is hereby granted, free of charge, to any person obtaining
49+a copy of the Font Software, to use, study, copy, merge, embed, modify,
50+redistribute, and sell modified and unmodified copies of the Font
51+Software, subject to the following conditions:
52+
53+1) Neither the Font Software nor any of its individual components,
54+in Original or Modified Versions, may be sold by itself.
55+
56+2) Original or Modified Versions of the Font Software may be bundled,
57+redistributed and/or sold with any software, provided that each copy
58+contains the above copyright notice and this license. These can be
59+included either as stand-alone text files, human-readable headers or
60+in the appropriate machine-readable metadata fields within text or
61+binary files as long as those fields can be easily viewed by the user.
62+
63+3) No Modified Version of the Font Software may use the Reserved Font
64+Name(s) unless explicit written permission is granted by the corresponding
65+Copyright Holder. This restriction only applies to the primary font name as
66+presented to the users.
67+
68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69+Software shall not be used to promote, endorse or advertise any
70+Modified Version, except to acknowledge the contribution(s) of the
71+Copyright Holder(s) and the Author(s) or with their explicit written
72+permission.
73+
74+5) The Font Software, modified or unmodified, in part or in whole,
75+must be distributed entirely under this license, and must not be
76+distributed under any other license. The requirement for fonts to
77+remain under this license does not apply to any document created
78+using the Font Software.
79+
80+TERMINATION
81+This license becomes null and void if any of the above conditions are
82+not met.
83+
84+DISCLAIMER
85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93+OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
1+Copyright 2022-2024 The Tiny5 Project Authors (https://github.com/Gissio/font_tiny5)2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+https://scripts.sil.org/OFL6+7+8+-----------------------------------------------------------9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200710+-----------------------------------------------------------11+12+PREAMBLE13+The goals of the Open Font License (OFL) are to stimulate worldwide14+development of collaborative font projects, to support the font creation15+efforts of academic and linguistic communities, and to provide a free and16+open framework in which fonts may be shared and improved in partnership17+with others.18+19+The OFL allows the licensed fonts to be used, studied, modified and20+redistributed freely as long as they are not sold by themselves. The21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved23+names are not used by derivative works. The fonts and derivatives,24+however, cannot be released under any other type of license. The25+requirement for fonts to remain under this license does not apply26+to any document created using the fonts or their derivatives.27+28+DEFINITIONS29+"Font Software" refers to the set of files released by the Copyright30+Holder(s) under this license and clearly marked as such. This may31+include source files, build scripts and documentation.32+33+"Reserved Font Name" refers to any names specified as such after the34+copyright statement(s).35+36+"Original Version" refers to the collection of Font Software components as37+distributed by the Copyright Holder(s).38+39+"Modified Version" refers to any derivative made by adding to, deleting,40+or substituting -- in part or in whole -- any of the components of the41+Original Version, by changing formats or by porting the Font Software to a42+new environment.43+44+"Author" refers to any designer, engineer, programmer, technical45+writer or other person who contributed to the Font Software.46+47+PERMISSION & CONDITIONS48+Permission is hereby granted, free of charge, to any person obtaining49+a copy of the Font Software, to use, study, copy, merge, embed, modify,50+redistribute, and sell modified and unmodified copies of the Font51+Software, subject to the following conditions:52+53+1) Neither the Font Software nor any of its individual components,54+in Original or Modified Versions, may be sold by itself.55+56+2) Original or Modified Versions of the Font Software may be bundled,57+redistributed and/or sold with any software, provided that each copy58+contains the above copyright notice and this license. These can be59+included either as stand-alone text files, human-readable headers or60+in the appropriate machine-readable metadata fields within text or61+binary files as long as those fields can be easily viewed by the user.62+63+3) No Modified Version of the Font Software may use the Reserved Font64+Name(s) unless explicit written permission is granted by the corresponding65+Copyright Holder. This restriction only applies to the primary font name as66+presented to the users.67+68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font69+Software shall not be used to promote, endorse or advertise any70+Modified Version, except to acknowledge the contribution(s) of the71+Copyright Holder(s) and the Author(s) or with their explicit written72+permission.73+74+5) The Font Software, modified or unmodified, in part or in whole,75+must be distributed entirely under this license, and must not be76+distributed under any other license. The requirement for fonts to77+remain under this license does not apply to any document created78+using the Font Software.79+80+TERMINATION81+This license becomes null and void if any of the above conditions are82+not met.83+84+DISCLAIMER85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM93+OTHER DEALINGS IN THE FONT SOFTWARE.Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
@@ -6,9 +6,13 @@
66 "night" palette is just the two values swapped — nothing here needs to know77 which way round they are. */88 9-@font-face { font-family: Silk; src: url(/fonts/silk-bold.woff2) format('woff2'); font-display: block; }10-@font-face { font-family: SilkR; src: url(/fonts/silk.woff2) format('woff2'); font-display: block; }11-@font-face { font-family: Mono; src: url(/fonts/mono.woff2) format('woff2'); font-display: block; }9+/* Two faces, and the pair is the hierarchy: Tiny5 is the display face, cut on10+ the same eight-pixel em Silkscreen was, and DM Mono is everything that is11+ read rather than announced. Tiny5 ships one weight, so the contrast between12+ a title and a caption is a change of *face* rather than of weight — which is13+ the stronger signal anyway, and the one a bitmap face can actually hold. */14+@font-face { font-family: Tiny5; src: url(/fonts/tiny5.woff2) format('woff2'); font-display: block; }15+@font-face { font-family: DMMono; src: url(/fonts/dm-mono.woff2) format('woff2'); font-display: block; }1216 1317 :root {1418 --ink: #2B2B22;@@ -27,7 +31,7 @@ html, body {
2731 visible seam the moment the two stop being the same colour. */2832 background: var(--ground);2933 color: var(--ink);30- font-family: Mono, ui-monospace, monospace;34+ font-family: DMMono, ui-monospace, monospace;3135 overflow: hidden;3236 overscroll-behavior: none; /* no pull-to-refresh mid-drag */3337 touch-action: none; /* the board owns every gesture */@@ -104,14 +108,14 @@ body {
104108 #acts { gap: calc(6px * var(--s)); }105109 106110 .stat { display: flex; flex-direction: column; align-items: center; gap: calc(2px * var(--s)); min-width: 0; }107-.val { font-family: Silk, monospace; font-size: calc(10px * var(--s)); }111+.val { font-family: Tiny5, monospace; font-size: calc(10px * var(--s)); }108112 .lbl { font-size: calc(5px * var(--s)); letter-spacing: calc(1px * var(--s)); opacity: .72; }109113 #gauge { justify-content: space-between; gap: 0; }110114 #gauge .spacer { flex: 1; }111115 112116 /* Buttons are QuickDraw buttons: a hard rectangle, inverted while pressed. */113117 button {114- font-family: Mono, monospace;118+ font-family: DMMono, monospace;115119 font-size: calc(6px * var(--s));116120 letter-spacing: calc(1.2px * var(--s));117121 color: var(--ink);@@ -1,4 +1,4 @@
1-const VERSION = "klotski-v5";1+const VERSION = "klotski-v6";22 const CORE = [33 "/",44 "/klotski.js",@@ -6,9 +6,8 @@ const CORE = [
66 "/bootstrap.js",77 "/solver-worker.js",88 "/manifest.webmanifest",9- "/fonts/silk.woff2",10- "/fonts/silk-bold.woff2",11- "/fonts/mono.woff2",9+ "/fonts/tiny5.woff2",10+ "/fonts/dm-mono.woff2",1211 "/icons/icon-192.png",1312 "/icons/icon-512.png",1413 "/icons/icon-maskable.png",@@ -2171,9 +2171,7 @@ fn sheet(model: Model) -> Element(Message) {
21712171 setting("HAPTICS", model.save.haptics, HapticsToggled),21722172 ]),21732173 html.div([attribute.class("credit")], [2174- html.text("ERROR DIFFUSION AND ORDERED SCREENS"),2175- html.br([]),2176- html.text("SILKSCREEN AND SPACE MONO, OFL"),2174+ html.text("FONTS BY STEFAN SCHMIDT & COLOPHON FOUNDRY"),21772175 ]),21782176 html.button([event.on_click(SettingsClosed)], [html.text("CLOSE")]),21792177 ],@@ -220,9 +220,8 @@ export function setThemeColour(ink, paper, ground) {
220220 221221 export function loadFonts(dispatch, message) {222222 const faces = [223- document.fonts.load("32px Silk"),224- document.fonts.load("32px SilkR"),225- document.fonts.load("16px Mono"),223+ document.fonts.load("32px Tiny5"),224+ document.fonts.load("16px DMMono"),226225 ];227226 Promise.all(faces)228227 .catch(() => {})@@ -24,7 +24,7 @@ import klotski/progress.{type Level}
2424 import gleam/option2525 import klotski/scene.{2626 type BlockStyle, type Op, type Screen, Block, BlockStyle, Center, Clip,27- DevLogo, End, Fill, GoldLine, Label, Screen, Silk, SilkR, Start, Trophy,27+ DevLogo, Display, End, Fill, GoldLine, Label, Screen, Start, Text, Trophy,2828 Unclip,2929 }3030 import klotski/shading.{type Pattern, type Ruling}@@ -178,7 +178,7 @@ pub fn boot(l: Layout, hero: Family, screen: Screen, theme: Theme) -> List(Op) {
178178 typeface.fit_size(179179 "KLOTSKI",180180 float.min(u *. 0.14, int.to_float(win.h) *. 0.19),181- Silk,181+ Display,182182 u *. 0.02,183183 int.to_float(win.w - 16),184184 )@@ -219,7 +219,7 @@ pub fn boot(l: Layout, hero: Family, screen: Screen, theme: Theme) -> List(Op) {
219219 x: cx,220220 y: int.to_float(title_y),221221 size: title_size,222- face: Silk,222+ face: Display,223223 track: u *. 0.02,224224 align: Center,225225 alpha: 1.0,@@ -230,7 +230,7 @@ pub fn boot(l: Layout, hero: Family, screen: Screen, theme: Theme) -> List(Op) {
230230 x: cx,231231 y: int.to_float(title_y + float.round(title_size *. 0.55)),232232 size: u *. 0.05,233- face: SilkR,233+ face: Text,234234 track: u *. 0.006,235235 align: Center,236236 alpha: 1.0,@@ -295,7 +295,7 @@ pub fn survey(
295295 x: cx,296296 y: vh *. 0.42,297297 size: u *. 0.05,298- face: SilkR,298+ face: Text,299299 track: u *. 0.006,300300 align: Center,301301 alpha: 1.0,@@ -306,7 +306,7 @@ pub fn survey(
306306 x: cx,307307 y: vh *. 0.53,308308 size: u *. 0.1,309- face: Silk,309+ face: Display,310310 track: u *. 0.008,311311 align: Center,312312 alpha: 1.0,@@ -320,7 +320,7 @@ pub fn survey(
320320 x: cx,321321 y: vh *. 0.575,322322 size: u *. 0.038,323- face: SilkR,323+ face: Text,324324 track: 0.0,325325 align: Center,326326 alpha: 0.75,@@ -331,7 +331,7 @@ pub fn survey(
331331 x: cx,332332 y: vh *. 0.645,333333 size: u *. 0.05,334- face: Silk,334+ face: Display,335335 track: u *. 0.01,336336 align: Center,337337 alpha: 1.0,@@ -361,8 +361,15 @@ pub type AtlasGeometry {
361361 width: Int,362362 board_col: Int,363363 tx: Int,364+ /// What the text column has, from `tx` to the card's right inset.365+ text_w: Int,364366 name_size: Float,365- stage_size: Float,367+ stat_size: Float,368+ foot_size: Float,369+ /// One difficulty mark, square.370+ mark: Int,371+ /// The meter's end stops.372+ tick: Int,366373 cols: Int,367374 gap_x: Int,368375 rows: Int,@@ -393,33 +400,52 @@ pub fn atlas_geometry(l: Layout, count: Int) -> AtlasGeometry {
393400 let board_col = float.round(int.to_float(width) *. 0.33)394401 let tx = 8 + board_col + 10395402 403+ // One column is a list and always scrolls — eight sets never fit a phone. Two404+ // columns usually can fit, so they are sized to, and the scroll goes away on405+ // anything with the room for it.406+ let top = l.bar_h + 8407+ let gap = 6408+ let row_h = case cols > 1 {409+ True ->410+ int.min(72, int.max(46, { l.vh - top - 8 } / int.max(1, rows) - gap))411+ False -> int.max(52, float.round(int.to_float(l.vh) *. 0.155))412+ }413+396414 // Type is sized off the card rather than off the picture, so a pair of397- // half-width cards sets its names for the space a half-width card has.398- let u = int.to_float(width) *. 1.08415+ // half-width cards sets its names for the space a half-width card has — and416+ // off *both* of the card's dimensions, not just its width. A card's height is417+ // clamped: between 46 and 72 units in two columns, and never under 52 in one.418+ // Its width is not clamped at all. Sizing type off the width alone therefore419+ // asked a 54-unit card to hold four lines cut for a 72-unit one, and the420+ // meter printed straight through the difficulty marks above it.421+ let u =422+ float.min(int.to_float(width) *. 1.08, int.to_float(row_h - 10) *. 6.7)399423 400424 // One size for every name, and the name gets the whole text column. Sharing401425 // that line with the stage number left so little room that the names came out402426 // smaller than the numbers beside them, which inverts the hierarchy of the403427 // card: the set is the subject, the stage is a detail about it.404- let avail = int.to_float(width - 8 - tx)428+ let text_w = width - 8 - tx405429 let name_size =406430 list.fold(families.all(), scene.snap(u *. 0.052), fn(size, family) {407431 float.min(408432 size,409- typeface.fit_size(family.label, size, Silk, u *. 0.006, avail),433+ typeface.fit_size(434+ family.label,435+ size,436+ Display,437+ u *. 0.006,438+ int.to_float(text_w),439+ ),410440 )411441 })412442 413- // One column is a list and always scrolls — eight sets never fit a phone. Two414- // columns usually can fit, so they are sized to, and the scroll goes away on415- // anything with the room for it.416- let top = l.bar_h + 8417- let gap = 6418- let row_h = case cols > 1 {419- True ->420- int.min(72, int.max(46, { l.vh - top - 8 } / int.max(1, rows) - gap))421- False -> int.max(52, float.round(int.to_float(l.vh) *. 0.155))422- }443+ // The marks and the meter are drawn rather than set, so they need sizes of444+ // their own — and they have to come off the same unit as the type, or a card445+ // whose type shrank keeps five-unit marks and a six-unit meter and the stack446+ // stops being a stack.447+ let stat_size = scene.snap(u *. 0.038)448+ let mark = int.max(3, float.round(stat_size *. 0.5))423449 424450 AtlasGeometry(425451 top:,@@ -429,8 +455,12 @@ pub fn atlas_geometry(l: Layout, count: Int) -> AtlasGeometry {
429455 width:,430456 board_col:,431457 tx:,458+ text_w:,432459 name_size:,433- stage_size: scene.snap(u *. 0.038),460+ stat_size:,461+ foot_size: scene.snap(u *. 0.03),462+ mark:,463+ tick: mark + 1,434464 cols:,435465 gap_x:,436466 rows:,@@ -438,6 +468,49 @@ pub fn atlas_geometry(l: Layout, count: Int) -> AtlasGeometry {
438468 )439469 }440470 471+/// Where the four lines of a card sit, in absolute units.472+///473+/// A card is a stack — a name, a line of numbers, the difficulty marks, then474+/// the meter — and each of those claims exactly the height of the type or the475+/// marks it is made of. What is left over is the air between them, shared out476+/// evenly and then capped, so a tall card sets its lines apart instead of477+/// drifting them into its corners.478+///479+/// The marks keep their slot on a card that has none. A played set and an480+/// unplayed one line their names up, which is the one thing a list of like481+/// items must not get wrong.482+type Lines {483+ Lines(name: Int, stat: Int, marks: Int, meter: Int)484+}485+486+fn atlas_lines(g: AtlasGeometry, r: Rect) -> Lines {487+ let name_h = typeface.cap_height(g.name_size, Display)488+ let stat_h = typeface.cap_height(g.stat_size, Text)489+ let mark_h = int.to_float(g.mark)490+ let meter_h =491+ float.max(typeface.cap_height(g.foot_size, Text), int.to_float(g.tick))492+ let content = name_h +. stat_h +. mark_h +. meter_h493+494+ let air = int.to_float(r.h - 10) -. content495+ let lead = float.max(2.0, float.min(air /. 3.0, g.name_size *. 0.6))496+ let top =497+ int.to_float(r.y)498+ +. { int.to_float(r.h) -. content -. 3.0 *. lead } /. 2.0499+500+ let stat_top = top +. name_h +. lead501+ let marks_top = stat_top +. stat_h +. lead502+ let meter_top = marks_top +. mark_h +. lead503+ Lines(504+ // Names and numbers are placed by the bottom of their capitals; everything505+ // in this app is set in capitals, so that is the line they sit on.506+ name: float.round(top +. name_h),507+ stat: float.round(stat_top +. stat_h),508+ marks: float.round(marks_top),509+ // The meter's rule, which its own caption is then centred against.510+ meter: float.round(meter_top +. meter_h /. 2.0),511+ )512+}513+441514 pub fn family_row_rect(g: AtlasGeometry, index: Int, scroll: Int) -> Rect {442515 Rect(443516 x: g.margin + index / g.rows * { g.width + g.gap_x },@@ -499,7 +572,7 @@ pub fn atlas(
499572 l.bar_h - float.round(int.to_float(l.bar_h) *. 0.28),500573 ),501574 size: int.to_float(l.bar_h) *. 0.5,502- face: Silk,575+ face: Display,503576 track: int.to_float(l.bar_h) *. 0.08,504577 align: Start,505578 alpha: 1.0,@@ -536,25 +609,51 @@ fn atlas_row(
536609 screen_for(pattern, shading.fit_ruling(ruling, { mini - 2 } * density, 0))537610 538611 let tx = r.x + g.tx612+ let lines = atlas_lines(g, r)613+614+ // The stage number is the fixed end of the line it shares, so it is measured615+ // first and the steps count is given what is left. Both used to run to the616+ // full width of the text column from opposite ends, and on a narrow card they617+ // met in the middle and overprinted each other.618+ let stage = "STAGE " <> int.to_string(row.rung)619+ let stage_track = u *. 0.004620+ let stage_w = typeface.measure(stage, g.stat_size, Text, stage_track)621+ let gutter = int.max(6, g.mark * 3)622+ let steps_w = int.to_float(g.text_w) -. stage_w -. int.to_float(gutter)623+539624 let body = case row.surveyed {540625 False -> [541- // Set in the middle of the space the played rows fill, rather than tucked542- // under the name leaving the rest of the card empty.626+ // On the meter's line, so a set you have not opened is the same card as627+ // one you have with its last line swapped, rather than a different shape.543628 Label(544629 text: "TAP TO BEGIN",545630 x: int.to_float(tx),546- y: int.to_float(r.y + { r.h + 26 } / 2 + 6),547- size: scene.snap(u *. 0.04),548- face: SilkR,631+ y: int.to_float(lines.meter)632+ +. typeface.cap_height(g.foot_size, Text) /. 2.0,633+ size: g.foot_size,634+ face: Text,549635 track: u *. 0.004,550636 align: Start,551637 alpha: 0.65,552- max_w: 0.0,638+ max_w: int.to_float(g.text_w),553639 ),554640 ]555641 True -> {556- let bar_w = int.min(r.w - { g.tx } - 34, 72)557- let bar_y = r.y + r.h - 13642+ let foot = case row.at_ceiling {643+ True -> "AT THE CEILING"644+ False -> "TO " <> int.to_string(row.ecc)645+ }646+ // The meter is what the caption beside it leaves: measured rather than647+ // guessed, because "AT THE CEILING" is four times the width of "TO 126"648+ // and a fixed seventy-two units had them fighting over the same space.649+ let foot_w = typeface.measure(foot, g.foot_size, Text, 0.0)650+ let bar_w =651+ int.max(652+ g.mark * 4,653+ g.text_w - float.round(foot_w) - int.max(4, g.mark),654+ )655+ let bar_y = lines.meter656+ let stop = g.tick558657 // How much of this family's depth you have walked. It fills as the rungs559658 // climb and stops when the ladder reaches the family's ceiling — which is560659 // the honest end of "deeper", not the end of the family.@@ -562,40 +661,39 @@ fn atlas_row(
562661 clamp01(563662 int.to_float(row.depth) /. int.to_float(int.max(1, row.ecc)),564663 )664+ let head = int.max(2, g.mark - 1)565665 list.flatten([566666 [567667 Label(568668 text: int.to_string(row.optimal) <> " STEPS",569669 x: int.to_float(tx),570- y: int.to_float(r.y + 31),571- size: scene.snap(u *. 0.036),572- face: SilkR,670+ y: int.to_float(lines.stat),671+ size: g.stat_size,672+ face: Text,573673 track: 0.0,574674 align: Start,575675 alpha: 0.85,576- max_w: 0.0,676+ max_w: float.max(steps_w, 0.0),577677 ),578678 ],579- marks(tx, r.y + 36, row.grade),679+ marks(tx, lines.marks, g.mark, row.grade),580680 [581681 scene.rule(tx, bar_y, bar_w, 1),582- scene.rule(tx, bar_y - 3, 1, 4),583- scene.rule(tx + bar_w - 1, bar_y - 3, 1, 4),682+ scene.rule(tx, bar_y - stop / 2, 1, stop),683+ scene.rule(tx + bar_w - 1, bar_y - stop / 2, 1, stop),584684 scene.rule(585- tx + float.round(walked *. int.to_float(bar_w - 3)),586- bar_y - 4,587- 3,588- 6,685+ tx + float.round(walked *. int.to_float(bar_w - head)),686+ bar_y - { stop + 2 } / 2,687+ head,688+ stop + 2,589689 ),590690 Label(591- text: case row.at_ceiling {592- True -> "AT THE CEILING"593- False -> "TO " <> int.to_string(row.ecc)594- },595- x: int.to_float(tx + bar_w + 6),596- y: int.to_float(bar_y + 3),597- size: scene.snap(u *. 0.03),598- face: SilkR,691+ text: foot,692+ x: int.to_float(tx + bar_w + int.max(4, g.mark)),693+ y: int.to_float(bar_y)694+ +. typeface.cap_height(g.foot_size, Text) /. 2.0,695+ size: g.foot_size,696+ face: Text,599697 track: 0.0,600698 align: Start,601699 alpha: 0.6,@@ -625,24 +723,24 @@ fn atlas_row(
625723 Label(626724 text: family.label,627725 x: int.to_float(tx),628- y: int.to_float(r.y + 16),726+ y: int.to_float(lines.name),629727 size: g.name_size,630- face: Silk,728+ face: Display,631729 track: u *. 0.006,632730 align: Start,633731 alpha: 1.0,634- max_w: 0.0,732+ max_w: int.to_float(g.text_w),635733 ),636734 Label(637- text: "STAGE " <> int.to_string(row.rung),735+ text: stage,638736 x: int.to_float(r.x + r.w - 8),639- y: int.to_float(r.y + 31),640- size: g.stage_size,641- face: SilkR,642- track: u *. 0.004,737+ y: int.to_float(lines.stat),738+ size: g.stat_size,739+ face: Text,740+ track: stage_track,643741 align: End,644742 alpha: 0.85,645- max_w: 0.0,743+ max_w: int.to_float(g.text_w) *. 0.5,646744 ),647745 ],648746 body,@@ -651,16 +749,17 @@ fn atlas_row(
651749 652750 /// Five marks, so a plate can wear its difficulty without a number: filled for653751 /// what it has, hollow for what it has not.654-fn marks(x: Int, y: Int, grade: Int) -> List(Op) {752+fn marks(x: Int, y: Int, size: Int, grade: Int) -> List(Op) {753+ let pitch = size + int.max(1, size / 3)655754 list.flat_map([0, 1, 2, 3, 4], fn(i) {656- let mx = x + i * 6755+ let mx = x + i * pitch657756 case i < grade {658- True -> [scene.rule(mx, y, 4, 4)]757+ True -> [scene.rule(mx, y, size, size)]659758 False -> [660- scene.rule(mx, y, 4, 1),661- scene.rule(mx, y + 3, 4, 1),662- scene.rule(mx, y, 1, 4),663- scene.rule(mx + 3, y, 1, 4),759+ scene.rule(mx, y, size, 1),760+ scene.rule(mx, y + size - 1, size, 1),761+ scene.rule(mx, y, 1, size),762+ scene.rule(mx + size - 1, y, 1, size),664763 ]665764 }666765 })@@ -694,7 +793,7 @@ pub fn card(
694793 x: cx,695794 y: int.to_float(win.y + float.round(int.to_float(win.h) *. 0.66)),696795 size: u *. 0.05,697- face: SilkR,796+ face: Text,698797 track: u *. 0.008,699798 align: Center,700799 alpha: 1.0,@@ -705,7 +804,7 @@ pub fn card(
705804 x: cx,706805 y: int.to_float(win.y + float.round(int.to_float(win.h) *. 0.87)),707806 size: u *. 0.042,708- face: SilkR,807+ face: Text,709808 track: u *. 0.006,710809 align: Center,711810 alpha: 0.75,@@ -724,7 +823,7 @@ pub fn card(
724823 x: cx,725824 y: int.to_float(win.y + float.round(int.to_float(win.h) *. 0.42)),726825 size: u *. 0.11,727- face: Silk,826+ face: Display,728827 track: u *. 0.016,729828 align: Center,730829 alpha: int.to_float(step) /. 4.0,@@ -809,7 +908,7 @@ pub fn play(
809908 - float.round(int.to_float(b.pad_bottom) *. 0.34),810909 ),811910 size: int.to_float(cell) *. 0.44,812- face: Silk,911+ face: Display,813912 track: 1.5,814913 align: Center,815914 alpha: 1.0,@@ -905,7 +1004,7 @@ pub fn won(
9051004 typeface.fit_size(9061005 "DISTANCE TO GOAL",9071006 int.to_float(b.cell) *. 0.36,908- SilkR,1007+ Text,9091008 1.0,9101009 int.to_float(plate.w - 18),9111010 )@@ -980,7 +1079,7 @@ pub fn won(
9801079 x: cx,9811080 y: int.to_float(solved_y),9821081 size: u *. 0.045,983- face: SilkR,1082+ face: Text,9841083 track: u *. 0.018,9851084 align: Center,9861085 alpha: 1.0,@@ -991,7 +1090,7 @@ pub fn won(
9911090 x: cx,9921091 y: int.to_float(solved_y + float.round(u *. 0.085)),9931092 size: u *. 0.075,994- face: Silk,1093+ face: Display,9951094 track: u *. 0.01,9961095 align: Center,9971096 alpha: 1.0,@@ -1017,7 +1116,7 @@ pub fn won(
10171116 x: cx,10181117 y: int.to_float(cap_y),10191118 size: cap_size,1020- face: SilkR,1119+ face: Text,10211120 track: 1.0,10221121 align: Center,10231122 alpha: 1.0,@@ -1108,7 +1207,7 @@ pub fn stats(
11081207 x: cx,11091208 y: int.to_float(y + 12),11101209 size: scene.snap(u *. 0.03),1111- face: SilkR,1210+ face: Text,11121211 track: 0.0,11131212 align: Center,11141213 alpha: 0.6,@@ -1135,7 +1234,7 @@ pub fn stats(
11351234 l.bar_h - float.round(int.to_float(l.bar_h) *. 0.28),11361235 ),11371236 size: int.to_float(l.bar_h) *. 0.5,1138- face: Silk,1237+ face: Display,11391238 track: int.to_float(l.bar_h) *. 0.08,11401239 align: Center,11411240 alpha: 1.0,@@ -1168,7 +1267,7 @@ fn level_panel(
11681267 x: int.to_float(m + 10),11691268 y: int.to_float(y + 16),11701269 size: scene.snap(u *. 0.036),1171- face: SilkR,1270+ face: Text,11721271 track: u *. 0.01,11731272 align: Start,11741273 alpha: 0.7,@@ -1179,7 +1278,7 @@ fn level_panel(
11791278 x: int.to_float(m + 10),11801279 y: int.to_float(y + float.round(int.to_float(h) *. 0.72)),11811280 size: scene.snap(u *. 0.11),1182- face: Silk,1281+ face: Display,11831282 track: u *. 0.006,11841283 align: Start,11851284 alpha: 1.0,@@ -1190,7 +1289,7 @@ fn level_panel(
11901289 x: int.to_float(m + w - 10),11911290 y: int.to_float(y + 16),11921291 size: scene.snap(u *. 0.036),1193- face: SilkR,1292+ face: Text,11941293 track: 0.0,11951294 align: End,11961295 alpha: 0.7,@@ -1216,7 +1315,7 @@ fn level_panel(
12161315 x: int.to_float(bx + bw),12171316 y: int.to_float(by + 16),12181317 size: scene.snap(u *. 0.03),1219- face: SilkR,1318+ face: Text,12201319 track: 0.0,12211320 align: End,12221321 alpha: 0.6,@@ -1279,7 +1378,7 @@ fn lifetime_panel(
12791378 x:,12801379 y: int.to_float(ry + float.round(int.to_float(row_h) *. 0.52)),12811380 size: scene.snap(u *. 0.048),1282- face: Silk,1381+ face: Display,12831382 track: 0.0,12841383 align: Center,12851384 alpha: 1.0,@@ -1293,7 +1392,7 @@ fn lifetime_panel(
12931392 x:,12941393 y: int.to_float(ry + float.round(int.to_float(row_h) *. 0.82)),12951394 size: u *. 0.028,1296- face: SilkR,1395+ face: Text,12971396 track: 0.0,12981397 align: Center,12991398 alpha: 0.65,@@ -1329,7 +1428,7 @@ fn family_table(
13291428 x: int.to_float(at(i)),13301429 y: int.to_float(y + 12),13311430 size: head,1332- face: SilkR,1431+ face: Text,13331432 track: 0.0,13341433 align:,13351434 alpha: 0.55,@@ -1365,10 +1464,10 @@ fn family_table(
13651464 )13661465 }13671466 [1368- figure(family.label, 0, Silk),1369- figure(int.to_string(record.rung), 1, SilkR),1370- figure(int.to_string(record.solved), 2, SilkR),1371- figure(int.to_string(record.deepest), 3, SilkR),1467+ figure(family.label, 0, Display),1468+ figure(int.to_string(record.rung), 1, Text),1469+ figure(int.to_string(record.solved), 2, Text),1470+ figure(int.to_string(record.deepest), 3, Text),13721471 ]13731472 })13741473 |> list.flatten@@ -1407,7 +1506,7 @@ fn recent_panel(
14071506 x: int.to_float(m + 8),14081507 y: int.to_float(y + 12),14091508 size: scene.snap(u *. 0.03),1410- face: SilkR,1509+ face: Text,14111510 track: 0.0,14121511 align: Start,14131512 alpha: 0.6,@@ -1421,7 +1520,7 @@ fn recent_panel(
14211520 x: cx,14221521 y: int.to_float(y + float.round(int.to_float(h) *. 0.62)),14231522 size: scene.snap(u *. 0.034),1424- face: SilkR,1523+ face: Text,14251524 track: 0.0,14261525 align: Center,14271526 alpha: 0.5,@@ -1485,7 +1584,7 @@ fn recent_panel(
14851584 x: int.to_float(m + w - 8),14861585 y: int.to_float(y + 12),14871586 size: scene.snap(u *. 0.03),1488- face: SilkR,1587+ face: Text,14891588 track: 0.0,14901589 align: End,14911590 alpha: 0.6,@@ -25,7 +25,7 @@ import {
2525 DevLogo,2626 Clip,2727 Unclip,28- Silk,28+ Display,2929 Center,3030 End,3131 } from "./scene.mjs";@@ -476,7 +476,7 @@ function blockSprite(op, env) {
476476 // --------------------------------------------------------------------- type477477 478478 function faceName(face) {479- return face instanceof Silk ? "Silk" : "SilkR";479+ return face instanceof Display ? "Tiny5" : "DMMono";480480 }481481 482482 function measure(ctx, text, size, face, track) {@@ -52,11 +52,17 @@ pub fn tone_of(class: Int) -> Int {
5252 }5353 }5454 55+/// The two faces, and the pair is the whole hierarchy.56+///57+/// Tiny5 has one weight, so a title is not a heavier caption — it is a58+/// different kind of letter. That is a stronger distinction than a weight59+/// anyway: a bitmap face at fourteen pixels cannot hold a bold convincingly,60+/// and the two it does hold end up looking like the same word printed twice.5561 pub type Face {56- /// Silkscreen bold: the display face.57- Silk58- /// Silkscreen regular: everything that is a caption.59- SilkR62+ /// Tiny5: the display face, for anything the picture announces.63+ Display64+ /// DM Mono: everything that is read rather than announced.65+ Text6066 }6167 6268 pub type Align {@@ -196,8 +202,8 @@ pub type Frame {
196202 // renderer, so that what a window is made of stays a fact about the design197203 // language and not a special case in the pixel loop.198204 199-/// Silkscreen is a bitmap face, so sizes are snapped to whole even pixels to200-/// keep its stems even.205+/// Tiny5 is a bitmap face on an eight-pixel em, so sizes are snapped to whole206+/// even pixels to keep its stems even.201207 pub fn snap(size: Float) -> Float {202208 float.max(2.0, int.to_float(float.round(size /. 2.0) * 2))203209 }@@ -1,4 +1,4 @@
1-/// How wide a line of type is.1+/// How wide a line of type is, and how tall.22 ///33 /// A pure function of the face, the size and the tracking — a table lookup that44 /// happens to live in a font file. The painters need it because several things@@ -7,20 +7,20 @@
77 /// caption that had to shrink to fit its cell still has to be spaced from what88 /// is above it.99 ///10-/// Silkscreen is a bitmap face, so sizes are snapped to whole even pixels or11-/// its stems come out uneven — which is `scene.snap`, and why nothing here ever10+/// Tiny5 is a bitmap face, so sizes are snapped to whole even pixels or its11+/// stems come out uneven — which is `scene.snap`, and why nothing here ever1212 /// returns an odd number.1313 import gleam/float1414 import gleam/int15-import klotski/scene.{type Face, Silk}15+import klotski/scene.{type Face, Display}1616 1717 @external(javascript, "./typeface_ffi.mjs", "measure")1818 fn measure_ffi(text: String, size: Float, face: String, track: Float) -> Float1919 2020 fn face_name(face: Face) -> String {2121 case face {22- Silk -> "Silk"23- _ -> "SilkR"22+ Display -> "Tiny5"23+ _ -> "DMMono"2424 }2525 }2626 @@ -29,6 +29,28 @@ pub fn measure(text: String, size: Float, face: Face, track: Float) -> Float {
2929 measure_ffi(text, size, face_name(face), track)3030 }3131 32+/// The height of a capital, as a fraction of the size asked for.33+///34+/// Everything in this app is set in capitals, so this — and not the em, and not35+/// the ascent — is the height a line of type actually occupies. Stacking by the36+/// em leaves a caption floating in space it does not fill; stacking by the cap37+/// is how the space between two lines stays the space you asked for when the38+/// faces change under it.39+pub fn cap(face: Face) -> Float {40+ case face {41+ // Both are cut with a five-pixel capital, Tiny5 on an eight-pixel em and DM42+ // Mono on a ten. Measured from the glyphs, not from the OS/2 table, which43+ // several pixel faces fill in optimistically.44+ Display -> 0.62545+ _ -> 0.746+ }47+}48+49+/// The height of a capital at a given size, in layout units.50+pub fn cap_height(size: Float, face: Face) -> Float {51+ size *. cap(face)52+}53+3254 /// The largest snapped size at or below `size` whose tracked width fits.3355 pub fn fit_size(3456 text: String,@@ -14,9 +14,12 @@ function context() {
1414 export function measure(text, size, face, track) {1515 const ctx = context();1616 // Before the faces load — and in any environment without a canvas at all,17- // which is what the headless checks are — Silkscreen's own advance is the18- // honest estimate: it is a bitmap face on a fixed grid.19- if (!ctx) return Math.max(0, text.length * size * 0.5 + (text.length - 1) * track);17+ // which is what the headless checks are — the faces' own advance is the18+ // honest estimate: both are cut on a fixed grid, Tiny5 at five-eighths of the19+ // em and DM Mono at three-fifths.20+ const advance = face === "Tiny5" ? 0.625 : 0.6;21+ if (!ctx)22+ return Math.max(0, text.length * size * advance + (text.length - 1) * track);2023 ctx.font = `${size}px ${face}`;2124 let width = -track;2225 for (const ch of text) width += ctx.measureText(ch).width + track;