commit 0b41af265e10e6b14530f569203737c750fdc2b9 Author: rhiannon morris Date: Thu Apr 17 05:23:18 2025 +0200 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48a004c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist-newstyle diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2167d51 --- /dev/null +++ b/Makefile @@ -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)/ diff --git a/ips.cabal b/ips.cabal new file mode 100644 index 0000000..c01e5e9 --- /dev/null +++ b/ips.cabal @@ -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 diff --git a/ips.lhs b/ips.lhs new file mode 100644 index 0000000..5d1fd0f --- /dev/null +++ b/ips.lhs @@ -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: + + + +> import Numeric +> import Data.Either +> import Test.QuickCheck +> import GHC.Generics + + + + +== 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) + +
+testing parser functions + + +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. + + +
+ +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" + +
+testing `bytes` + + +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 + + +
+ +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 + +
+testing `bytesWhile` + + +> 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)) + + +
+ + +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} +
+ + + + + P + + + + A + + + + T + + + + C + + + + H + + + + Chunk1 + + + + Chunk2 + + + + Chunk3 + + + + + + + + E + + + + O + + + + F + + +
+``` + +> 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} +
+ + + + + off (3) + + + + "00" (2) + + + + size (2) + + + + val (1) + + +
+``` + +> 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} +
+ + + + + off (3) + + + + size (2) + + + + data (size) … + + +
+``` + +> 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 + + +
+ +testing `bpsNumber` + + + +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 + + +
+ + +> 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 diff --git a/style/cross.svg b/style/cross.svg new file mode 100644 index 0000000..bc74351 --- /dev/null +++ b/style/cross.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + diff --git a/style/dark-stripes.png b/style/dark-stripes.png new file mode 100644 index 0000000..3834ca4 Binary files /dev/null and b/style/dark-stripes.png differ diff --git a/style/kinda-jean.png b/style/kinda-jean.png new file mode 100644 index 0000000..ca30d8b Binary files /dev/null and b/style/kinda-jean.png differ diff --git a/style/rec/MonoBdItalic.woff2 b/style/rec/MonoBdItalic.woff2 new file mode 100644 index 0000000..6f541d5 Binary files /dev/null and b/style/rec/MonoBdItalic.woff2 differ diff --git a/style/rec/MonoBold.woff2 b/style/rec/MonoBold.woff2 new file mode 100644 index 0000000..e52b84d Binary files /dev/null and b/style/rec/MonoBold.woff2 differ diff --git a/style/rec/MonoItalic.woff2 b/style/rec/MonoItalic.woff2 new file mode 100644 index 0000000..d50b50b Binary files /dev/null and b/style/rec/MonoItalic.woff2 differ diff --git a/style/rec/MonoLight.woff2 b/style/rec/MonoLight.woff2 new file mode 100644 index 0000000..c54efa3 Binary files /dev/null and b/style/rec/MonoLight.woff2 differ diff --git a/style/rec/MonoLtItalic.woff2 b/style/rec/MonoLtItalic.woff2 new file mode 100644 index 0000000..c06d594 Binary files /dev/null and b/style/rec/MonoLtItalic.woff2 differ diff --git a/style/rec/MonoRegular.woff2 b/style/rec/MonoRegular.woff2 new file mode 100644 index 0000000..69fc2e5 Binary files /dev/null and b/style/rec/MonoRegular.woff2 differ diff --git a/style/rec/MonoSemiBd.woff2 b/style/rec/MonoSemiBd.woff2 new file mode 100644 index 0000000..cce2073 Binary files /dev/null and b/style/rec/MonoSemiBd.woff2 differ diff --git a/style/rec/MonoSmBdItalic.woff2 b/style/rec/MonoSmBdItalic.woff2 new file mode 100644 index 0000000..3f9e680 Binary files /dev/null and b/style/rec/MonoSmBdItalic.woff2 differ diff --git a/style/rec/SansBdItalic.woff2 b/style/rec/SansBdItalic.woff2 new file mode 100644 index 0000000..21950fb Binary files /dev/null and b/style/rec/SansBdItalic.woff2 differ diff --git a/style/rec/SansBold.woff2 b/style/rec/SansBold.woff2 new file mode 100644 index 0000000..00dee64 Binary files /dev/null and b/style/rec/SansBold.woff2 differ diff --git a/style/rec/SansItalic.woff2 b/style/rec/SansItalic.woff2 new file mode 100644 index 0000000..ea4bdd9 Binary files /dev/null and b/style/rec/SansItalic.woff2 differ diff --git a/style/rec/SansLight.woff2 b/style/rec/SansLight.woff2 new file mode 100644 index 0000000..e58ef56 Binary files /dev/null and b/style/rec/SansLight.woff2 differ diff --git a/style/rec/SansLtItalic.woff2 b/style/rec/SansLtItalic.woff2 new file mode 100644 index 0000000..0833677 Binary files /dev/null and b/style/rec/SansLtItalic.woff2 differ diff --git a/style/rec/SansRegular.woff2 b/style/rec/SansRegular.woff2 new file mode 100644 index 0000000..3711c84 Binary files /dev/null and b/style/rec/SansRegular.woff2 differ diff --git a/style/rec/SansSemiBd.woff2 b/style/rec/SansSemiBd.woff2 new file mode 100644 index 0000000..8f6775d Binary files /dev/null and b/style/rec/SansSemiBd.woff2 differ diff --git a/style/rec/SansSmBdItalic.woff2 b/style/rec/SansSmBdItalic.woff2 new file mode 100644 index 0000000..ce761c5 Binary files /dev/null and b/style/rec/SansSmBdItalic.woff2 differ diff --git a/style/rec/rec.css b/style/rec/rec.css new file mode 100644 index 0000000..d75a124 --- /dev/null +++ b/style/rec/rec.css @@ -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"; +} diff --git a/style/style.css b/style/style.css new file mode 100644 index 0000000..7724036 --- /dev/null +++ b/style/style.css @@ -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: ""; + 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; +} diff --git a/template.html b/template.html new file mode 100644 index 0000000..c13712b --- /dev/null +++ b/template.html @@ -0,0 +1,46 @@ + + + + + + +$for(css)$ + +$endfor$ + +$for(header-includes)$ + $header-includes$ +$endfor$ + +$if(title-prefix)$$title-prefix$ – $endif$$pagetitle$ + +$for(include-before)$ +$include-before$ +$endfor$ + +$if(title)$ +
+

$title$

+$if(subtitle)$ +

$subtitle$ +$endif$ +

+$endif$ + +$if(toc)$ + +$endif$ + +
+$body$ +
+ +$for(include-after)$ +$include-after$ +$endfor$