This commit is contained in:
rhiannon morris 2025-04-17 05:23:18 +02:00
commit 0b41af265e
26 changed files with 1103 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
dist-newstyle

41
Makefile Normal file
View file

@ -0,0 +1,41 @@
# only for the web page. use cabal if you actually want to run the program
OUT := /srv/www/lit/ips
STATICS != find style -type f
SRCS = ips.lhs
OUTPUTS = $(OUT)/ips.html \
$(patsubst %,$(OUT)/%,$(STATICS))
HOST ?= rhiannon.website
SSH_PORT ?= 35353
REMOTE_USER ?= nginx
REMOTE_DIR ?= lit/ips
all: $(OUTPUTS)
clean:
rm -rf $(OUT)/*
$(OUT)/%.html: %.lhs template.html ; $(pandoc)
$(OUT)/%: % ; $(copy)
define copy
mkdir -p $(dir $@)
cp $< $@
endef
define pandoc
mkdir -p $(dir $@)
pandoc -M toc-title=contents --toc-depth 2 --toc \
--strip-comments \
--template template.html $< -o $@
endef
upload:
rsync --recursive --partial --progress --copy-links \
--compress --human-readable --hard-links --size-only \
--delete --delete-after \
--rsh='ssh -l $(REMOTE_USER) -p $(SSH_PORT)' \
$(OUT)/ $(HOST):$(REMOTE_DIR)/

31
ips.cabal Normal file
View file

@ -0,0 +1,31 @@
cabal-version: 3.0
name: ips
version: 2
flag tests
manual: True
default: True
executable ips2
main-is: ips.lhs
build-depends:
base,
bytestring,
mtl,
random,
QuickCheck
ghc-options: -O -Wall
default-language: GHC2024
default-extensions:
CPP
BlockArguments
DerivingVia
OverloadedStrings
OverloadedLists
StrictData
if flag(tests)
build-depends:
QuickCheck
cpp-options: -DTESTS
ghc-options: -Wno-missing-signatures

548
ips.lhs Normal file
View file

@ -0,0 +1,548 @@
---
title: applying patches
...
stuff like rom hacks are usually distributed in one of two formats:
[IPS][] or [BPS][].
[IPS]: http://fileformats.archiveteam.org/wiki/IPS_(binary_patch_format)
[BPS]: https://github.com/blakesmith/rombp/blob/master/docs/bps_spec.md
> import Control.Applicative
> import Control.Monad
> import Control.Monad.Except
> import Control.Monad.State
> import Data.Bits
> import Data.ByteString (ByteString)
> import Data.ByteString qualified as BS
> import Data.Word
> import Numeric.Natural
and for tests:
<!--
#ifdef TESTS
-->
> import Numeric
> import Data.Either
> import Test.QuickCheck
> import GHC.Generics
<!--
#endif
-->
== basic parsers
i could just use attoparsec or something, and in the original code i did, but
that's not really needed for a program like this, so let's make our own. it's
good enough for these purposes. the error messages are bad, but what are you
going to do, hand-edit an IPS file???
a parser has read-write access to the input, a `ByteString`; and the ability to
throw errors (string messages are fine for this).
> newtype Parser a = Parser (StateT ByteString (Except String) a)
> deriving (Functor, Applicative, Alternative, Monad, MonadPlus)
when running a parser we want to check that if it was successful, then it did
actually consume all of the input. but not doing so, and returning the remainder
instead, will be useful for tests.
> runParserPartial :: Parser a -> ByteString -> Either String (a, ByteString)
> runParserPartial (Parser p) str = runExcept (runStateT p str)
>
> runParser :: Parser a -> ByteString -> Either String a
> runParser p str = do
> (result, rest) <- runParserPartial p str
> if BS.null rest then
> Right result
> else
> Left ("junk after EOF: " ++ show rest)
<details>
<summary>testing parser functions</summary>
<!--
#ifdef TESTS
-->
to test these parsers we can generate random inputs and check that they do the
right thing. a test input is a byte string, but printed with each byte in hex.
> newtype ParserInput = ParserInput { fromParserInput :: ByteString }
>
> instance Show ParserInput where
> show = unwords . map showHex2 . piToList
>
> showHex2 :: Word8 -> String
> showHex2 n = case showHex n "" of [c] -> ['0', c]; str -> str
>
> piToList :: ParserInput -> [Word8]
> piToList = BS.unpack . fromParserInput
>
> listToPI :: [Word8] -> ParserInput
> listToPI = ParserInput . BS.pack
like so:
```
ghci> ParserInput "\0\0\0"
00 00 00
ghci> ParserInput "Hello"
48 65 6c 6c 6f
```
in many cases, we don't really care about the structure of the input, so it can
just be a sequence of any bytes. quickcheck uses the `Arbitrary` class for
generating test input. the optional `shrink` function produces smaller but
related values, for trying to find a minimal test case. the instances for
`CoArbitrary` and `Function` is for generating functions which take
`ParserInput` as an argument.
> instance Arbitrary ParserInput where
> arbitrary :: Gen ParserInput
> arbitrary = listToPI <$> arbitrary
>
> shrink :: ParserInput -> [ParserInput]
> shrink = map listToPI . shrinkList (const []) . piToList
>
> instance CoArbitrary ParserInput where
> coarbitrary = coarbitrary . piToList
>
> instance Function ParserInput where
> function = functionMap piToList listToPI
in this case, `shrink` tries to remove some bytes from the string, without
touching individual bytes.
<!--
#endif
-->
</details>
when consuming a given number of characters, we check here that there is
actually enough input left.
> bytes :: Int -> Parser ByteString
> bytes len = Parser do
> (this, rest) <- BS.splitAt len <$> get
> if BS.length this == len then do
> put rest
> pure this
> else
> throwError "tried to read past end of input"
<details>
<summary>testing `bytes`</summary>
<!--
#ifdef TESTS
-->
for these tests, we need a generator for a string together with a valid
prefix length. if we tell quickcheck to just guess random numbers with no help,
it'll give up before running enough tests.
> data InputWithPrefix = ParserInput `WithPrefix` Int
> deriving (Show, Generic)
>
> instance Arbitrary InputWithPrefix where
> arbitrary = do
> input <- arbitrary
> len <- chooseInt (0, BS.length (fromParserInput input))
> pure (input `WithPrefix` len)
> shrink = genericShrink
now, the properties we want are:
if the remaining input is too short, then `bytes` should fail.
> prop_bytes_short (ParserInput str) (NonNegative count) =
> BS.length str < count ==> isLeft (runParserPartial (bytes count) str)
if there _is_ enough input, it should succeed.
> prop_bytes_enough (ParserInput str `WithPrefix` count) =
> isRight (runParserPartial (bytes count) str)
the returned string (the first element of the returned pair) should have the
right length.
> prop_bytes_length (ParserInput str `WithPrefix` count) =
> withSuccess (bytes count) str \(result, _) -> BS.length result === count
appending the returned string and the remainder should give the original input.
> prop_bytes_append (ParserInput str `WithPrefix` count) =
> withSuccess (bytes count) str \(result, rest) -> result <> rest === str
<!--
#endif
-->
</details>
it is also useful to take input while a given condition is true. in this case,
the parsing always succeeds but might be empty.
> bytesWhile :: (Word8 -> Bool) -> Parser ByteString
> bytesWhile cond = Parser do
> (this, rest) <- BS.span cond <$> get
> put rest; pure this
<details>
<summary>testing `bytesWhile`</summary>
<!--
#ifdef TESTS
-->
> withSuccess :: Testable p =>
> Parser a -> ByteString -> ((a, ByteString) -> p) -> Property
> withSuccess p str f =
> case runParserPartial p str of
> Left _ -> property False
> Right x -> property (f x)
`bytesWhile` always succeeds (with a possibly-empty result).
> prop_bytesWhile_succeed (Fn p) (ParserInput str) =
> isRight (runParserPartial (bytesWhile p) str)
`bytesWhile p` returns a value where `p` holds for each byte. this test also
categorises cases by result length, and requires the remainder to be non-empty,
to check that we're not accidentally creating only constant functions.
> prop_bytesWhile_result (Fn p) (ParserInput str) = do
> withSuccess (bytesWhile p) str \(result, rest) ->
> collect (BS.length result)
> (not (BS.null rest) ==> BS.all p result)
if `bytesWhile` doesn't consume the whole input, then `p` _doesn't_ hold for the
first byte of the remainder.
> prop_bytesWhile_rest (Fn p) (ParserInput str) =
> withSuccess (bytesWhile p) str \(_, rest) ->
> not (BS.null rest) ==> not (p (BS.head rest))
<!--
#endif
-->
</details>
checking for an exact string can be written in terms of `bytes`.
> exact :: ByteString -> Parser ()
> exact str = do
> prefix <- bytes (BS.length str)
> unless (prefix == str) do
> Parser (throwError ("expected " ++ show str ++ ", got " ++ show prefix))
to detect the end of input, it's simplest to just look at the remaining input
directly.
> eof :: Parser ()
> eof = Parser do
> input <- get
> unless (BS.null input) do
> throwError ("expected end of input, got " ++ show input)
reading an integer just takes the appropriate number of bytes and shifts them
together. **multi-byte integers in the IPS format are big-endian.**
> word8 :: Parser Word8
> word8 = BS.head <$> bytes 1
>
> word16BE :: Parser Word16
> word16BE = do
> hi <- word8; lo <- word8
> pure $ fromIntegral hi `shiftL` 8 .|. fromIntegral lo
>
> word24BE :: Parser Word32
> word24BE = do
> hi <- word16BE; lo <- word8
> pure $ fromIntegral hi `shiftL` 8 .|. fromIntegral lo
== IPS file format
the IPS format is extremely simple. it consists of the ASCII string `PATCH`, a
number of [chunks](#chunks), and finally the string `EOF`.
```{=html}
<figure class=shadowed>
<svg viewBox="-1 -1 602 32" width=602 height=32>
<style>
rect { stroke-width: 1px; stroke: currentcolor; }
text { text-anchor: middle; }
</style>
<g>
<rect width=15 height=30 fill="oklch(95% 15% 220deg)" />
<text x=7 y=22> P </text>
</g>
<g transform="translate(15 0)">
<rect width=15 height=30 fill="oklch(95% 15% 220deg)" />
<text x=7 y=22> A </text>
</g>
<g transform="translate(30 0)">
<rect width=15 height=30 fill="oklch(95% 15% 220deg)" />
<text x=7 y=22> T </text>
</g>
<g transform="translate(45 0)">
<rect width=15 height=30 fill="oklch(95% 15% 220deg)" />
<text x=7 y=22> C </text>
</g>
<g transform="translate(60 0)">
<rect width=15 height=30 fill="oklch(95% 15% 220deg)" />
<text x=7 y=22> H </text>
</g>
<g transform="translate(75 0)">
<rect width=80 height=30 fill="oklch(95% 15% 180deg)" />
<text x=40 y=22> Chunk1 </text>
</g>
<g transform="translate(155 0)">
<rect width=180 height=30 fill="oklch(95% 15% 160deg)" />
<text x=90 y=22> Chunk2 </text>
</g>
<g transform="translate(335 0)">
<rect width=120 height=30 fill="oklch(95% 15% 140deg)" />
<text x=60 y=22> Chunk3 </text>
</g>
<g transform="translate(455 0)">
<rect width=100 height=30 fill="oklch(95% 15% 120deg)" />
<text x=50 y=22> … </text>
</g>
<g transform="translate(555 0)">
<rect width=15 height=30 fill="oklch(95% 15% 100deg)" />
<text x=7 y=22> E </text>
</g>
<g transform="translate(570 0)">
<rect width=15 height=30 fill="oklch(95% 15% 100deg)" />
<text x=7 y=22> O </text>
</g>
<g transform="translate(585 0)">
<rect width=15 height=30 fill="oklch(95% 15% 100deg)" />
<text x=7 y=22> F </text>
</g>
</svg>
</figure>
```
> type IPS = [IpsChunk]
>
> ipsFile :: Parser IPS
> ipsFile = do exact "PATCH"; ipsChunks
if the rest of the input is just the `EOF` marker (followed by the actual end),
then we're done. otherwise, parse a chunk and repeat.
explicitly checking for the end of input as well means that chunks with an
offset of `0x454F46`, which will _look_ like `EOF`, are still handled
correctly.
> ipsChunks :: Parser [IpsChunk]
> ipsChunks = stop <|> go where
> stop = do exact "EOF"
> eof
> pure []
> go = do next <- ipsChunk
> rest <- ipsChunks
> pure (next : rest)
=== chunks
a chunk can either be a [run-length-encoded](#rle) or a [literal](#lit) chunk.
in either case, they are preceded by a 24-bit offset giving their start
position.
> data IpsChunk = IpsChunk { offset :: Word32, body :: IpsChunkBody }
>
> data IpsChunkBody
> = Lit ByteString
> | RLE { size :: Word16, value :: Word8 }
>
> ipsChunk :: Parser IpsChunk
> ipsChunk = IpsChunk <$> word24BE <*> (rle <|> lit)
a [**run-length encoded chunk**]{#rle} indicates a single byte being repeated a
given number of times. it is represented by two zero bytes, followed by a
two-byte length, and a single byte value. the zero bytes exist to distinguish it
from a literal chunk, which cannot have a zero length.
```{=html}
<figure class=shadowed>
<svg viewBox="-1 -1 642 32" width=642 height=32>
<style>
rect { stroke-width: 1px; stroke: currentcolor; }
text { text-anchor: middle }
</style>
<g>
<rect width=240 height=30 fill="oklch(95% 15% 80deg)" />
<text x=120 y=22> off (3) </text>
</g>
<g transform="translate(240 0)">
<rect width=160 height=30 fill="oklch(95% 15% 60deg)" />
<text x=80 y=22> "00" (2) </text>
</g>
<g transform="translate(400 0)">
<rect width=160 height=30 fill="oklch(95% 15% 40deg)" />
<text x=80 y=22> size (2) </text>
</g>
<g transform="translate(560 0)">
<rect width=80 height=30 fill="oklch(95% 15% 20deg)" />
<text x=40 y=22> val (1) </text>
</g>
</svg>
</figure>
```
> rle :: Parser IpsChunkBody
> rle = do
> exact [0,0]
> size <- word16BE
> value <- word8
> pure (RLE { size, value })
a [**literal chunk**]{#lit} consists of a 16-bit (non-zero) length, followed by
the data it contains. actually checking the size is non-zero isn't needed,
because `rle` is attempted first and will succeed in that case.
```{=html}
<figure class=shadowed>
<svg viewBox="-1 -1 642 32" width=642 height=32>
<style>
rect, path { stroke-width: 1px; stroke: currentcolor; }
text { text-anchor: middle; }
</style>
<g>
<rect width=240 height=30 fill="oklch(95% 15% 80deg)" />
<text x=120 y=22> off (3) </text>
</g>
<g transform="translate(240 0)">
<rect width=160 height=30 fill="oklch(95% 15% 60deg)" />
<text x=80 y=22> size (2) </text>
</g>
<g transform="translate(400 0)">
<path fill="oklch(95% 15% 40deg)"
d="M 240,0 h -240 v 30 h 240
l -5,-5 5,-5 -5,-5 5,-5 -5,-5 z" />
<text x=120 y=22> data (<tspan font-style=italic>size</tspan>) … </text>
</g>
</svg>
</figure>
```
> lit :: Parser IpsChunkBody
> lit = do
> size <- word16BE
> Lit <$> bytes (fromIntegral size)
== BPS file format
BPS uses arbitrary-size numbers using a variable-length, **little-endian**
encoding.
Each byte contains seven bits of data, using the most significant bit to
indicate whether the number continues. One extra quirk is that each non-final
byte is also decremented. the rationale for this is that otherwise, it would be
possible to encode `1` as `0x81` but also as `0x01_80`, with the meaning
`000 0000 000 0001` with extra zero padding. with this extra step, the second
encoding actually represents `000 0001 000 0001`, or 129.
so we read the input until reaching a byte with the MSB set, plus one more, then
shift them all together, right to left.
> bpsNumber :: Parser Natural
> bpsNumber = do
> rest <- bytesWhile \b -> not (testBit b 7)
> end <- word8
> pure (BS.foldr' combine (fromIntegral (clearBit end 7)) rest)
> where
> combine byte acc = fromIntegral byte .|. (acc + 1) `shiftL` 7
<details>
<summary>
testing `bpsNumber`
</summary>
<!--
#ifdef TESTS
-->
this function is _almost_ a nice fold, but i'm not yet 100% certain the `(+ 1)`
is in the right place. so here's a more literal translation of the C code:
> bpsNumberSpec :: Parser Natural
> bpsNumberSpec = loop 0 1 where
> loop acc shft = do
> byte <- word8
> let acc' = acc + fromIntegral (clearBit byte 7) * shft
> let shft' = shft `shiftL` 7
> if testBit byte 7 then
> pure acc'
> else
> loop (acc' + shft') shft'
a valid encoding of a number is a string of zero or more bytes with the MSB
unset, followed by one with it set.
> newtype BpsNumber = BpsNumber ByteString
> deriving Show via ParserInput
>
> instance Arbitrary BpsNumber where
> arbitrary = do
> rest :: [Word8] <- arbitrary
> final :: Word8 <- arbitrary
> let list = map (\b -> clearBit b 7) rest ++ [setBit final 7]
> pure (BpsNumber (BS.pack list))
both functions should accept any string of this format, and should each give the
same result.
> prop_bpsOk (BpsNumber e) =
> let bps' = runParser bpsNumberSpec e
> bps = runParser bpsNumber e in
> isRight bps' .&&. isRight bps .&&. bps' === bps
<!--
#endif
-->
</details>
> props :: [(String, Property)]
> props =
> [("prop_bytes_short", property prop_bytes_short),
> ("prop_bytes_enough", property prop_bytes_enough),
> ("prop_bytes_length", property prop_bytes_length),
> ("prop_bytes_append", property prop_bytes_append),
> ("prop_bytesWhile_succeed", property prop_bytesWhile_succeed),
> ("prop_bytesWhile_result", property prop_bytesWhile_result),
> ("prop_bytesWhile_rest", property prop_bytesWhile_rest),
> ("prop_bpsOk", property prop_bpsOk)]
>
> testAll :: IO ()
> testAll = forM_ props \(name, prop) -> do
> putStr (name ++ ": ")
> quickCheck prop
== applying a patch
> _outSize :: [IpsChunk] -> ByteString -> Int
> _outSize patch orig =
> foldl' (\s c -> max s (end c)) (BS.length orig) patch
> where
> end (IpsChunk offset body) = fromIntegral offset + size body
> size (Lit str) = BS.length str
> size (RLE sz _) = fromIntegral sz
> main :: IO ()
> main = testAll

38
style/cross.svg Normal file
View file

@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100" width="50" height="50">
<style><![CDATA[
:root {
opacity: 0.75;
--light: hsl(215deg, 14%, 62%);
--dark: hsl(236deg, 10%, 33%);
}
* {
transform-box: border-box;
transform-origin: 50% 50%;
}
#outline, #shadow {
stroke: var(--dark);
stroke-width: 12px;
}
#fill { fill: var(--light); }
#shadow {
opacity: 0.3;
transform: translate(5px, 5px);
}
]]></style>
<defs>
<g id="plus">
<rect x="10" y="40" width="80" height="20" />
<rect x="40" y="10" width="20" height="80" />
</g>
</defs>
<g transform="rotate(-45)">
<use href="#plus" id="shadow" />
<use href="#plus" id="outline" />
<use href="#plus" id="fill" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 764 B

BIN
style/dark-stripes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
style/kinda-jean.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

BIN
style/rec/MonoBold.woff2 Normal file

Binary file not shown.

BIN
style/rec/MonoItalic.woff2 Normal file

Binary file not shown.

BIN
style/rec/MonoLight.woff2 Normal file

Binary file not shown.

Binary file not shown.

BIN
style/rec/MonoRegular.woff2 Normal file

Binary file not shown.

BIN
style/rec/MonoSemiBd.woff2 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
style/rec/SansBold.woff2 Normal file

Binary file not shown.

BIN
style/rec/SansItalic.woff2 Normal file

Binary file not shown.

BIN
style/rec/SansLight.woff2 Normal file

Binary file not shown.

Binary file not shown.

BIN
style/rec/SansRegular.woff2 Normal file

Binary file not shown.

BIN
style/rec/SansSemiBd.woff2 Normal file

Binary file not shown.

Binary file not shown.

145
style/rec/rec.css Normal file
View file

@ -0,0 +1,145 @@
@font-face {
font-family: 'RecSans';
font-style: normal;
font-weight: 300;
src: url(SansLight.woff2);
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
}
@font-face {
font-family: 'RecSans';
font-style: italic;
font-weight: 300;
src: url(SansLtItalic.woff2);
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
}
@font-face {
font-family: 'RecSans';
font-style: normal;
font-weight: 400;
src: url(SansRegular.woff2);
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
}
@font-face {
font-family: 'RecSans';
font-style: italic;
font-weight: 400;
src: url(SansItalic.woff2);
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
}
@font-face {
font-family: 'RecSans';
font-style: normal;
font-weight: 600;
src: url(SansSemiBd.woff2);
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
}
@font-face {
font-family: 'RecSans';
font-style: italic;
font-weight: 600;
src: url(SansSmBdItalic.woff2);
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
}
@font-face {
font-family: 'RecSans';
font-style: normal;
font-weight: 700;
src: url(SansBold.woff2);
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
}
@font-face {
font-family: 'RecSans';
font-style: italic;
font-weight: 600;
src: url(SansBdItalic.woff2);
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
}
@font-face {
font-family: 'RecMono';
font-style: normal;
font-weight: 300;
src: url(MonoLight.woff2);
font-feature-settings:
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
"ss10", "ss12";
}
@font-face {
font-family: 'RecMono';
font-style: italic;
font-weight: 300;
src: url(MonoLtItalic.woff2);
font-feature-settings:
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
"ss10", "ss12";
}
@font-face {
font-family: 'RecMono';
font-style: normal;
font-weight: 400;
src: url(MonoRegular.woff2);
font-feature-settings:
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
"ss10", "ss12";
}
@font-face {
font-family: 'RecMono';
font-style: italic;
font-weight: 400;
src: url(MonoItalic.woff2);
font-feature-settings:
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
"ss10", "ss12";
}
@font-face {
font-family: 'RecMono';
font-style: normal;
font-weight: 600;
src: url(MonoSemiBd.woff2);
font-feature-settings:
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
"ss10", "ss12";
}
@font-face {
font-family: 'RecMono';
font-style: italic;
font-weight: 600;
src: url(MonoSmBdItalic.woff2);
font-feature-settings:
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
"ss10", "ss12";
}
@font-face {
font-family: 'RecMono';
font-style: normal;
font-weight: 700;
src: url(MonoBold.woff2);
font-feature-settings:
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
"ss10", "ss12";
}
@font-face {
font-family: 'RecMono';
font-style: italic;
font-weight: 600;
src: url(MonoBdItalic.woff2);
font-feature-settings:
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
"ss10", "ss12";
}

253
style/style.css Normal file
View file

@ -0,0 +1,253 @@
@import url(rec/rec.css);
* { box-sizing: border-box; }
:root {
--body-font: RecSans;
--mono-font: RecMono;
--base-hue: 300deg;
font-family: var(--body-font);
background:
url(dark-stripes.png) fixed,
linear-gradient(to bottom,
oklch(60% 23% var(--base-hue)),
oklch(45% 18% var(--base-hue))) fixed;
background-blend-mode: multiply;
}
body {
background: url(kinda-jean.png), oklch(93% 10% var(--base-hue));
background-blend-mode: multiply;
padding: 2em 4em;
width: min(95%, 54em);
margin: 3em auto;
border-radius: 1em;
border: 3px solid oklch(20% 15% var(--base-hue));
box-shadow: 1em 0.667em 2em oklch(30% 15% var(--base-hue) / 50%);
}
header {
text-align: center;
h1 {
font-weight: 200;
font-size: 250%;
margin: 0 0 2rem;
padding: 0 0 0.333rem;
border-bottom: 2px dotted currentcolor;
}
}
:root { --marker-color: oklch(40% 40% var(--base-hue)); }
:is(main, nav) :is(h2, h3, h4, summary) {
font-weight: normal;
font-style: italic;
margin: 1.5em 0 0.25em -1.5rem;
}
:is(main, nav) :is(h2, h3, h4) {
&::before {
content: '♡';
margin-right: 0.5em;
color: var(--marker-color);
font-size: 90%;
display: inline-block;
rotate: -23deg;
}
}
h2 { font-size: 150%; }
h3 { font-size: 125%; }
h4 { font-size: 100%; }
code, .iname {
font-family: var(--mono-font);
font-size: inherit;
}
pre { font-size: 85%; }
.idesc {
font-variant: small-caps;
font-weight: 500;
}
a {
--link-hue: calc(var(--base-hue) + 150deg);
--link-col: oklch(40% 60% var(--link-hue));
--link-col-fade: oklch(35% 50% var(--link-hue));
color: var(--link-col);
text-decoration: 1px dotted underline;
text-decoration-color: var(--link-col-fade);
&[href^='#'] { --link-hue: var(--base-hue); }
&:visited { color: var(--link-col-fade); }
}
code {
.al {} /* Alert */
.an {} /* Annotation */
.at {} /* Attribute */
.bn {} /* BaseN */
.bu {} /* BuiltIn */
.cf {} /* ControlFlow */
.ch {} /* Char */
.cn {} /* Constant */
.co {
/* Comment */
color: oklch(30% 10% var(--base-hue));
/* font-style: italic; */
font-family: var(--body-font);
}
.do {} /* Documentation */
.dt {} /* DataType */
.dv {} /* DecVal */
.er {} /* Error */
.ex {} /* Extension */
.fl {} /* Float */
.fu {} /* Function */
.im {} /* Import */
.in {} /* Information */
.kw { font-weight: 600; } /* Keyword */
.op {} /* Operator */
.pp {} /* Preprocessor */
.sc {} /* SpecialChar */
.ss {} /* SpecialString */
.st {} /* String */
.va {} /* Variable */
.vs {} /* VerbatimString */
.wa {} /* Warning */
}
@property --box-hue {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
pre,
div:is(.todo, .note),
aside:is(.left, .right) {
--bg-texture: url(kinda-jean.png);
/* --bg-color: oklch(93% 17% var(--box-hue)); */
--bg-gradient:
linear-gradient(in oklch to right,
oklch(91% 15% var(--box-hue)),
oklch(87% 18% var(--box-hue)));
--base-bg: var(--bg-texture), var(--bg-gradient);
background: var(--base-bg);
background-blend-mode: multiply;
border: 1px solid oklch(35% 35% var(--box-hue));
border-radius: 0.5rem;
box-shadow: 0.5rem 0.333rem 0.5rem oklch(45% 25% var(--box-hue) / 25%);
margin: 1rem 2rem;
padding: 0.5rem 1rem;
& + & { margin-top: 0.5rem !important; }
}
pre { --box-hue: 250deg; }
.literate { --box-hue: var(--base-hue); }
.todo { --box-hue: 350deg; }
.note, aside { --box-hue: 90deg; }
pre.haskell:not(.literate) {
--box-hue: 20deg;
background:
url(cross.svg) bottom 10px right 10px no-repeat,
var(--base-bg);
}
.note {
font-size: 90%;
font-style: italic;
}
figure {
width: fit-content;
margin: auto;
&.shadowed {
filter:
drop-shadow(0.5rem 0.333rem 0.5rem oklch(45% 25% var(--base-hue) / 25%));
}
}
dl {
display: grid;
grid-template-columns: auto auto;
}
dt {
text-align: right;
font-weight: bold;
grid-area: auto / 1 / auto / auto;
padding-right: 2em;
}
dd {
grid-area: auto / 2 / auto / auto;
display: list-item;
}
dt, dd { margin: 0; }
dt, dt + dd {
margin-top: 1em;
}
:is(nav, ul) > li, dd {
list-style: '→ ';
}
:is(li, dd)::marker {
font-size: 80%;
color: var(--marker-color);
}
aside {
font-size: 90%;
&.left, &.right {
width: 30%;
margin: 0.5rem 0;
padding: 0.25em 0.75em;
}
&.left {
float: left;
margin-right: 1em;
}
&.right {
float: right;
margin-left: 1em;
}
}
summary {
font-size: 125%;
font-style: italic;
&::marker { color: var(--marker-color); }
&::after {
content: '[show]';
font-size: 75%;
font-style: normal;
margin-left: 1rem;
}
[open] > &::after { content: '[hide]'; }
}
details[open]::after, nav::after {
content: '';
display: block;
height: 2px;
background:
linear-gradient(to right,
transparent, var(--marker-color) 20%,
var(--marker-color) 80%, transparent);
opacity: 60%;
width: 60%;
margin: 1rem auto 2rem;
}

46
template.html Normal file
View file

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport
content="width=device-width, initial-scale=1.0, user-scalable=yes">
<link rel=stylesheet href=/ips/style/style.css>
$for(css)$
<link rel=stylesheet href="$css$">
$endfor$
$for(header-includes)$
$header-includes$
$endfor$
<title>$if(title-prefix)$$title-prefix$ $endif$$pagetitle$</title>
$for(include-before)$
$include-before$
$endfor$
$if(title)$
<header>
<h1>$title$</h1>
$if(subtitle)$
<p>$subtitle$
$endif$
</header>
$endif$
$if(toc)$
<nav id="toc" role="doc-toc">
$if(toc-title)$
<h2>$toc-title$</h2>
$endif$
$table-of-contents$
</nav>
$endif$
<main>
$body$
</main>
$for(include-after)$
$include-after$
$endfor$