diff --git a/blog/images/01_why_simurgarray_should_exist.jpg b/blog/images/01_why_simurgarray_should_exist.jpg
index 1d71a37..ecf22d3 100644
Binary files a/blog/images/01_why_simurgarray_should_exist.jpg and b/blog/images/01_why_simurgarray_should_exist.jpg differ
diff --git a/blog/images/02_alternatives_and_why_i_built_it_anyway.jpg b/blog/images/02_alternatives_and_why_i_built_it_anyway.jpg
index 8b61bb7..cd4cb6b 100644
Binary files a/blog/images/02_alternatives_and_why_i_built_it_anyway.jpg and b/blog/images/02_alternatives_and_why_i_built_it_anyway.jpg differ
diff --git a/blog/images/03_what_good_it_could_do.jpg b/blog/images/03_what_good_it_could_do.jpg
index f78e59c..474d15f 100644
Binary files a/blog/images/03_what_good_it_could_do.jpg and b/blog/images/03_what_good_it_could_do.jpg differ
diff --git a/blog/images/04_going_public_plan_and_calendar.jpg b/blog/images/04_going_public_plan_and_calendar.jpg
index 2ce296c..13640ee 100644
Binary files a/blog/images/04_going_public_plan_and_calendar.jpg and b/blog/images/04_going_public_plan_and_calendar.jpg differ
diff --git a/blog/images/05_using_simurgarray_and_the_bird_behind_the_name.jpg b/blog/images/05_using_simurgarray_and_the_bird_behind_the_name.jpg
index 6a465f8..4330421 100644
Binary files a/blog/images/05_using_simurgarray_and_the_bird_behind_the_name.jpg and b/blog/images/05_using_simurgarray_and_the_bird_behind_the_name.jpg differ
diff --git a/branding/README.md b/branding/README.md
new file mode 100644
index 0000000..3293e24
--- /dev/null
+++ b/branding/README.md
@@ -0,0 +1,171 @@
+# SimurgArray brand guide
+
+Everything here is generated by `scripts/brand/make_brand.py` (logos, icons, templates, checks) and
+`scripts/blog/make_featured_images.py` (blog images). The mark geometry lives in one place,
+`scripts/brand/simurg_mark.py`, so every file shows exactly the same dots. Artwork is released under CC BY 4.0; the
+fonts are Noto Sans and Noto Sans Mono (SIL Open Font License 1.1).
+
+
+
+## The story in three sentences
+
+In Attar's *Conference of the Birds*, thirty birds search for the Simurg and find that "si murg" means thirty birds:
+the Simurg was all of them together. A genotyping array is the same idea, hundreds of thousands of small beads that
+only mean something when read together. SimurgArray is the tool that reads them together, openly.
+
+Tagline: **Thirty birds, one picture.**
+
+## The mark
+
+A simurg seen from the front, wings raised, drawn only with round dots on a square grid, the way beads sit on a
+BeadChip.
+
+* **Gold body, wings and crown.** Gold is what you get when the two scanner channels meet.
+* **Three tail plumes:** red on the left (the red channel), gold in the middle, green on the right (the green
+ channel). Each ends in a round peacock eye.
+* **Three dots on the head** form a small crown. Nobody has to notice it.
+
+Three drawings of the same bird, chosen by size:
+
+* `mark` (31 x 27 dots): 100 px and larger, banners, lockups, print.
+* `mark_simple` (11 x 10 dots): 24 to 100 px, report corners, small UI.
+* `mark_micro` (7 x 7 dots): 16 px favicon only.
+
+Colour versions of each, in `logo/` as SVG and in `logo/png/` as PNG:
+
+* `dark`: gold #D4A017 with red and green plumes, for indigo or other dark backgrounds.
+* `light`: deep gold #A87A00 with red and green plumes, for paper or white.
+* `gold`, `black`, `white`: single colour, for stamps, embossing, merchandise, fax machines that still exist.
+
+The SVGs contain only circles, no embedded images and no fonts, so they render the same everywhere.
+
+### Wordmark and lockups
+
+"Simurg" in the text colour and "Array" in the accent colour, Noto Sans SemiBold, outlined to paths so no font is
+needed. On dark backgrounds the accent is gold #D4A017, on light backgrounds gold ink #8C6500.
+
+* `lockup_horizontal_*`: mark left, wordmark right. The default.
+* `lockup_stacked_*`: mark above, wordmark centred below. For square spaces.
+
+Clear space around any lockup: at least the height of the mark's head (about three dot rows) on every side.
+
+### Do
+
+* Keep the dots round and on their grid.
+* Keep the red plume on the left and the green plume on the right.
+* Use the light version on light backgrounds; plain gold on paper is too faint (contrast 2.18).
+* Scale the whole mark; pick the simple or micro drawing when the dots would get smaller than about 2 px.
+
+### Do not
+
+* Recolour the plumes, add gradients, shadows, glows or outlines.
+* Turn dots into squares or connect them with lines.
+* Rotate, mirror or stretch the mark.
+* Put the mark on busy photos.
+* Combine it with DNA helices, crosses, pills or any clinical imagery. SimurgArray is a research tool.
+* Use the logos, colours or product names of any genomics company as visual elements. Illumina and Infinium appear
+ only as plain words describing compatibility.
+
+## Colour
+
+Brand colours:
+
+* **Night indigo** #1E2240: backgrounds (most of the work happened at night). Deep indigo #12142A for gradients.
+* **Paper** #F7F5EF: light backgrounds and text on dark.
+* **Simurg gold** #D4A017: the bird, accents on dark.
+* **Gold deep** #A87A00: the bird on light backgrounds.
+* **Gold ink** #8C6500: accent text on light backgrounds.
+* **Channel red** #C8453A and **channel green** #2F8F5B: the plumes and the bead grid only. Never for text.
+* **Muted on dark** #A3A7C9 and **muted on light** #5A5E7E: secondary text.
+
+Rough proportions on a dark page: indigo 80, paper 10, gold 7, red and green 3 together.
+
+Every text colour passes WCAG AA (4.5) and every graphic colour passes 3.0 on its background. The full table with
+the computed ratios is in `checks/contrast.md`.
+
+### Charts
+
+Red against green is exactly the pair that red green colour blind readers cannot separate, and in genotyping it is
+also the most tempting pair. So charts never use the channel colours to tell data apart. Use the Okabe and Ito
+palette with a second cue:
+
+* AA: vermillion #D55E00, circles.
+* AB: bluish green #009E73, triangles.
+* BB: blue #0072B2, squares.
+
+Always add shape, position or a direct label as well as colour. `checks/colour_vision_check.png` shows the mark and
+an example cluster plot under simulated protanopia and deuteranopia (Machado et al. 2009). The bird stays readable
+because its shape carries it; the two coloured plumes become similar but keep their left and right positions; the
+chart stays readable because of the shapes.
+
+## Type
+
+* **Noto Sans** for everything people read. Headings SemiBold or Bold, body Regular.
+* **Noto Sans Mono** for anything a user might paste into a terminal, file names, and small labels such as
+ "PART 3".
+* Numbers in tables use tabular figures (`font-variant-numeric: tabular-nums`), so columns of call rates line up.
+
+Suggested sizes for the web: body 17 px with 1.6 line height, H1 40 px, H2 28 px, H3 21 px, code 15 px, table text
+15 px. Reports: 13 px tables, 12 px captions.
+
+## The dot grid
+
+New illustrations are built from the same material as the logo.
+
+* Background grid: dots every 18 px (at 1200 px width), radius 1.6 px, alternating red and green at 14 % opacity
+ over the indigo gradient.
+* Foreground dots: radius 40 % of the cell, so neighbours never touch.
+* Colour by meaning: gold for the subject, red and green only for things that come from the two channels, dimmed
+ gold or indigo mixes for "background" subjects.
+* Motion is suggested with trails of shrinking, fading dots, never with lines.
+* One subject per image. Empty space is part of the grid.
+
+## Templates
+
+In `templates/`:
+
+* `featured_template_1200x630.jpg`: WordPress featured image and social preview for posts. Title on the left in Noto
+ Sans Bold (58 px, shrinks to fit a 540 px column), "PART N" above it in gold mono, footer with three dots, the name
+ and the tagline. The five blog images in `blog/images/` follow this layout, each with its own small motif.
+* `social_preview_1280x640.png`: the repository preview image.
+* `readme_banner_1280x320.png`: the top of the README.
+* `avatar_512.png`: square avatar; the mark sits inside the central circle, so round crops are safe.
+* `report_corner_mark.svg` and `report_corner_mark.html`: the small mark for HTML QC reports. It is always shown with
+ the software version and the first 12 characters of the run manifest checksum. The bird is for recognition; the
+ checksum is what actually proves anything.
+* `cli_banner.txt`: six lines, 46 columns, plain ASCII, for `simurg --version` or a first run.
+
+Icons in `icons/`: `favicon.ico` (16, 32, 48), PNGs at 16, 32, 48, 180 (Apple touch) and 512, all on a rounded
+indigo tile. `checks/icon_sizes.png` shows them side by side.
+
+## Making a new featured image
+
+1. Add the post to `POSTS` in `scripts/blog/make_featured_images.py`: file stem, "Part N", the title with `\n`
+ where lines should break, and a motif.
+2. Reuse a motif or write a new one: draw the mark with `draw_mark`, add at most one supporting element.
+3. Run `python3 scripts/blog/make_featured_images.py`. Files come out at 1200 x 630 and about 100 KB.
+
+## Voice
+
+* Plain, exact English. Short sentences. Numbers instead of adjectives.
+* Every claim sits next to its limitation, in the same paragraph.
+* Humour only as a faint smile.
+* No em dashes, en dashes or hyphens in prose; rephrase instead. Hyphens in code, file names and options are fine.
+* Never: revolutionary, cutting edge, AI powered, next generation, seamless, unlock.
+
+Examples:
+
+* Yes: "Concordance with 30x sequencing was 99.44 %, against 99.46 % for the vendor's cluster file."
+* No: "Best in class accuracy that rivals commercial solutions."
+* Yes: "Research use only. The PGx report shows the evidence behind every call."
+* No: "Personalised medicine, unlocked."
+
+## Acceptance checks
+
+1. **Recognisable at 48 px, distinctive at 16 px:** see `checks/icon_sizes.png`. The simple and micro drawings were
+ drawn by hand for these sizes instead of scaling the detailed one down.
+2. **Contrast:** all text pairs pass AA, all graphic pairs pass 3.0 (`checks/contrast.md`); plain gold on paper is
+ the one pair that fails, and the guide forbids it.
+3. **Colour vision:** `checks/colour_vision_check.png`.
+4. **One family:** `checks/family.jpg` puts the five blog images and the templates side by side.
+5. **No dashes in prose:** this guide and all generated text were checked for em dashes, en dashes and hyphens.
diff --git a/branding/checks/colour_vision_check.png b/branding/checks/colour_vision_check.png
new file mode 100644
index 0000000..7d66ee6
Binary files /dev/null and b/branding/checks/colour_vision_check.png differ
diff --git a/branding/checks/contrast.md b/branding/checks/contrast.md
new file mode 100644
index 0000000..64aacdd
--- /dev/null
+++ b/branding/checks/contrast.md
@@ -0,0 +1,14 @@
+| colour | on | use | contrast | AA text (4.5) | graphics (3.0) |
+|---|---|---|---|---|---|
+| paper #F7F5EF | indigo #1E2240 | text on dark | 14.18 | yes | yes |
+| muted_dark #A3A7C9 | indigo #1E2240 | secondary text on dark | 6.57 | yes | yes |
+| gold #D4A017 | indigo #1E2240 | accent text on dark | 6.51 | yes | yes |
+| indigo #1E2240 | paper #F7F5EF | text on light | 14.18 | yes | yes |
+| muted_light #5A5E7E | paper #F7F5EF | secondary text on light | 5.77 | yes | yes |
+| gold_ink #8C6500 | paper #F7F5EF | accent text on light | 4.84 | yes | yes |
+| red #C8453A | indigo #1E2240 | graphics on dark | 3.22 | no | yes |
+| green #2F8F5B | indigo #1E2240 | graphics on dark | 3.83 | no | yes |
+| gold_deep #A87A00 | paper #F7F5EF | graphics on light | 3.54 | no | yes |
+| red #C8453A | paper #F7F5EF | graphics on light | 4.41 | no | yes |
+| green #2F8F5B | paper #F7F5EF | graphics on light | 3.70 | no | yes |
+| gold #D4A017 | paper #F7F5EF | never for text or small marks | 2.18 | no | no |
diff --git a/branding/checks/family.jpg b/branding/checks/family.jpg
new file mode 100644
index 0000000..71806c9
Binary files /dev/null and b/branding/checks/family.jpg differ
diff --git a/branding/checks/icon_sizes.png b/branding/checks/icon_sizes.png
new file mode 100644
index 0000000..ebaaa1b
Binary files /dev/null and b/branding/checks/icon_sizes.png differ
diff --git a/branding/icons/favicon.ico b/branding/icons/favicon.ico
new file mode 100644
index 0000000..bf7c901
Binary files /dev/null and b/branding/icons/favicon.ico differ
diff --git a/branding/icons/icon_16.png b/branding/icons/icon_16.png
new file mode 100644
index 0000000..b29a0c0
Binary files /dev/null and b/branding/icons/icon_16.png differ
diff --git a/branding/icons/icon_16.svg b/branding/icons/icon_16.svg
new file mode 100644
index 0000000..0d9b13f
--- /dev/null
+++ b/branding/icons/icon_16.svg
@@ -0,0 +1 @@
+
diff --git a/branding/icons/icon_180.png b/branding/icons/icon_180.png
new file mode 100644
index 0000000..376f5c7
Binary files /dev/null and b/branding/icons/icon_180.png differ
diff --git a/branding/icons/icon_32.png b/branding/icons/icon_32.png
new file mode 100644
index 0000000..db4680b
Binary files /dev/null and b/branding/icons/icon_32.png differ
diff --git a/branding/icons/icon_48.png b/branding/icons/icon_48.png
new file mode 100644
index 0000000..6e098d8
Binary files /dev/null and b/branding/icons/icon_48.png differ
diff --git a/branding/icons/icon_512.png b/branding/icons/icon_512.png
new file mode 100644
index 0000000..9a4fe38
Binary files /dev/null and b/branding/icons/icon_512.png differ
diff --git a/branding/icons/icon_512.svg b/branding/icons/icon_512.svg
new file mode 100644
index 0000000..5dc329c
--- /dev/null
+++ b/branding/icons/icon_512.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/lockup_horizontal_black.svg b/branding/logo/lockup_horizontal_black.svg
new file mode 100644
index 0000000..59be904
--- /dev/null
+++ b/branding/logo/lockup_horizontal_black.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/lockup_horizontal_dark.svg b/branding/logo/lockup_horizontal_dark.svg
new file mode 100644
index 0000000..90526fa
--- /dev/null
+++ b/branding/logo/lockup_horizontal_dark.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/lockup_horizontal_light.svg b/branding/logo/lockup_horizontal_light.svg
new file mode 100644
index 0000000..c40b605
--- /dev/null
+++ b/branding/logo/lockup_horizontal_light.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/lockup_horizontal_white.svg b/branding/logo/lockup_horizontal_white.svg
new file mode 100644
index 0000000..4c4300b
--- /dev/null
+++ b/branding/logo/lockup_horizontal_white.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/lockup_stacked_black.svg b/branding/logo/lockup_stacked_black.svg
new file mode 100644
index 0000000..0843858
--- /dev/null
+++ b/branding/logo/lockup_stacked_black.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/lockup_stacked_dark.svg b/branding/logo/lockup_stacked_dark.svg
new file mode 100644
index 0000000..9b4ebeb
--- /dev/null
+++ b/branding/logo/lockup_stacked_dark.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/lockup_stacked_light.svg b/branding/logo/lockup_stacked_light.svg
new file mode 100644
index 0000000..9bdc01d
--- /dev/null
+++ b/branding/logo/lockup_stacked_light.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/lockup_stacked_white.svg b/branding/logo/lockup_stacked_white.svg
new file mode 100644
index 0000000..80a5609
--- /dev/null
+++ b/branding/logo/lockup_stacked_white.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_black.svg b/branding/logo/mark_black.svg
new file mode 100644
index 0000000..72b087a
--- /dev/null
+++ b/branding/logo/mark_black.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_dark.svg b/branding/logo/mark_dark.svg
new file mode 100644
index 0000000..e6309ba
--- /dev/null
+++ b/branding/logo/mark_dark.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_gold.svg b/branding/logo/mark_gold.svg
new file mode 100644
index 0000000..0fc89db
--- /dev/null
+++ b/branding/logo/mark_gold.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_light.svg b/branding/logo/mark_light.svg
new file mode 100644
index 0000000..140f091
--- /dev/null
+++ b/branding/logo/mark_light.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_micro_black.svg b/branding/logo/mark_micro_black.svg
new file mode 100644
index 0000000..1a56e8f
--- /dev/null
+++ b/branding/logo/mark_micro_black.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_micro_dark.svg b/branding/logo/mark_micro_dark.svg
new file mode 100644
index 0000000..e9b0f5c
--- /dev/null
+++ b/branding/logo/mark_micro_dark.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_micro_gold.svg b/branding/logo/mark_micro_gold.svg
new file mode 100644
index 0000000..1569708
--- /dev/null
+++ b/branding/logo/mark_micro_gold.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_micro_light.svg b/branding/logo/mark_micro_light.svg
new file mode 100644
index 0000000..c82bea8
--- /dev/null
+++ b/branding/logo/mark_micro_light.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_micro_white.svg b/branding/logo/mark_micro_white.svg
new file mode 100644
index 0000000..74db5b4
--- /dev/null
+++ b/branding/logo/mark_micro_white.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_simple_black.svg b/branding/logo/mark_simple_black.svg
new file mode 100644
index 0000000..b5f6b39
--- /dev/null
+++ b/branding/logo/mark_simple_black.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_simple_dark.svg b/branding/logo/mark_simple_dark.svg
new file mode 100644
index 0000000..092c95a
--- /dev/null
+++ b/branding/logo/mark_simple_dark.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_simple_gold.svg b/branding/logo/mark_simple_gold.svg
new file mode 100644
index 0000000..c2c5b48
--- /dev/null
+++ b/branding/logo/mark_simple_gold.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_simple_light.svg b/branding/logo/mark_simple_light.svg
new file mode 100644
index 0000000..7f1d731
--- /dev/null
+++ b/branding/logo/mark_simple_light.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_simple_white.svg b/branding/logo/mark_simple_white.svg
new file mode 100644
index 0000000..d75c42c
--- /dev/null
+++ b/branding/logo/mark_simple_white.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/mark_white.svg b/branding/logo/mark_white.svg
new file mode 100644
index 0000000..cdda24b
--- /dev/null
+++ b/branding/logo/mark_white.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/png/lockup_horizontal_black.png b/branding/logo/png/lockup_horizontal_black.png
new file mode 100644
index 0000000..780125e
Binary files /dev/null and b/branding/logo/png/lockup_horizontal_black.png differ
diff --git a/branding/logo/png/lockup_horizontal_dark.png b/branding/logo/png/lockup_horizontal_dark.png
new file mode 100644
index 0000000..ef07987
Binary files /dev/null and b/branding/logo/png/lockup_horizontal_dark.png differ
diff --git a/branding/logo/png/lockup_horizontal_light.png b/branding/logo/png/lockup_horizontal_light.png
new file mode 100644
index 0000000..df5769d
Binary files /dev/null and b/branding/logo/png/lockup_horizontal_light.png differ
diff --git a/branding/logo/png/lockup_horizontal_white.png b/branding/logo/png/lockup_horizontal_white.png
new file mode 100644
index 0000000..6c0039f
Binary files /dev/null and b/branding/logo/png/lockup_horizontal_white.png differ
diff --git a/branding/logo/png/lockup_stacked_black.png b/branding/logo/png/lockup_stacked_black.png
new file mode 100644
index 0000000..1e57890
Binary files /dev/null and b/branding/logo/png/lockup_stacked_black.png differ
diff --git a/branding/logo/png/lockup_stacked_dark.png b/branding/logo/png/lockup_stacked_dark.png
new file mode 100644
index 0000000..19cd310
Binary files /dev/null and b/branding/logo/png/lockup_stacked_dark.png differ
diff --git a/branding/logo/png/lockup_stacked_light.png b/branding/logo/png/lockup_stacked_light.png
new file mode 100644
index 0000000..c967fef
Binary files /dev/null and b/branding/logo/png/lockup_stacked_light.png differ
diff --git a/branding/logo/png/lockup_stacked_white.png b/branding/logo/png/lockup_stacked_white.png
new file mode 100644
index 0000000..dc9031c
Binary files /dev/null and b/branding/logo/png/lockup_stacked_white.png differ
diff --git a/branding/logo/png/mark_black.png b/branding/logo/png/mark_black.png
new file mode 100644
index 0000000..31ff153
Binary files /dev/null and b/branding/logo/png/mark_black.png differ
diff --git a/branding/logo/png/mark_dark.png b/branding/logo/png/mark_dark.png
new file mode 100644
index 0000000..9f3fd86
Binary files /dev/null and b/branding/logo/png/mark_dark.png differ
diff --git a/branding/logo/png/mark_gold.png b/branding/logo/png/mark_gold.png
new file mode 100644
index 0000000..e39dbde
Binary files /dev/null and b/branding/logo/png/mark_gold.png differ
diff --git a/branding/logo/png/mark_light.png b/branding/logo/png/mark_light.png
new file mode 100644
index 0000000..68a1c3d
Binary files /dev/null and b/branding/logo/png/mark_light.png differ
diff --git a/branding/logo/png/mark_micro_black.png b/branding/logo/png/mark_micro_black.png
new file mode 100644
index 0000000..44225ba
Binary files /dev/null and b/branding/logo/png/mark_micro_black.png differ
diff --git a/branding/logo/png/mark_micro_dark.png b/branding/logo/png/mark_micro_dark.png
new file mode 100644
index 0000000..a1804b3
Binary files /dev/null and b/branding/logo/png/mark_micro_dark.png differ
diff --git a/branding/logo/png/mark_micro_gold.png b/branding/logo/png/mark_micro_gold.png
new file mode 100644
index 0000000..e8724c9
Binary files /dev/null and b/branding/logo/png/mark_micro_gold.png differ
diff --git a/branding/logo/png/mark_micro_light.png b/branding/logo/png/mark_micro_light.png
new file mode 100644
index 0000000..1a8c8e5
Binary files /dev/null and b/branding/logo/png/mark_micro_light.png differ
diff --git a/branding/logo/png/mark_micro_white.png b/branding/logo/png/mark_micro_white.png
new file mode 100644
index 0000000..1ec261f
Binary files /dev/null and b/branding/logo/png/mark_micro_white.png differ
diff --git a/branding/logo/png/mark_simple_black.png b/branding/logo/png/mark_simple_black.png
new file mode 100644
index 0000000..087eb2d
Binary files /dev/null and b/branding/logo/png/mark_simple_black.png differ
diff --git a/branding/logo/png/mark_simple_dark.png b/branding/logo/png/mark_simple_dark.png
new file mode 100644
index 0000000..4b0fec7
Binary files /dev/null and b/branding/logo/png/mark_simple_dark.png differ
diff --git a/branding/logo/png/mark_simple_gold.png b/branding/logo/png/mark_simple_gold.png
new file mode 100644
index 0000000..4b93e47
Binary files /dev/null and b/branding/logo/png/mark_simple_gold.png differ
diff --git a/branding/logo/png/mark_simple_light.png b/branding/logo/png/mark_simple_light.png
new file mode 100644
index 0000000..17f827d
Binary files /dev/null and b/branding/logo/png/mark_simple_light.png differ
diff --git a/branding/logo/png/mark_simple_white.png b/branding/logo/png/mark_simple_white.png
new file mode 100644
index 0000000..efb0f6d
Binary files /dev/null and b/branding/logo/png/mark_simple_white.png differ
diff --git a/branding/logo/png/mark_white.png b/branding/logo/png/mark_white.png
new file mode 100644
index 0000000..d3518ef
Binary files /dev/null and b/branding/logo/png/mark_white.png differ
diff --git a/branding/logo/png/wordmark_black.png b/branding/logo/png/wordmark_black.png
new file mode 100644
index 0000000..d1fa61a
Binary files /dev/null and b/branding/logo/png/wordmark_black.png differ
diff --git a/branding/logo/png/wordmark_dark.png b/branding/logo/png/wordmark_dark.png
new file mode 100644
index 0000000..9be8916
Binary files /dev/null and b/branding/logo/png/wordmark_dark.png differ
diff --git a/branding/logo/png/wordmark_gold.png b/branding/logo/png/wordmark_gold.png
new file mode 100644
index 0000000..bd7c855
Binary files /dev/null and b/branding/logo/png/wordmark_gold.png differ
diff --git a/branding/logo/png/wordmark_light.png b/branding/logo/png/wordmark_light.png
new file mode 100644
index 0000000..9826252
Binary files /dev/null and b/branding/logo/png/wordmark_light.png differ
diff --git a/branding/logo/png/wordmark_white.png b/branding/logo/png/wordmark_white.png
new file mode 100644
index 0000000..a50b254
Binary files /dev/null and b/branding/logo/png/wordmark_white.png differ
diff --git a/branding/logo/wordmark_black.svg b/branding/logo/wordmark_black.svg
new file mode 100644
index 0000000..5a41587
--- /dev/null
+++ b/branding/logo/wordmark_black.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/wordmark_dark.svg b/branding/logo/wordmark_dark.svg
new file mode 100644
index 0000000..1364c75
--- /dev/null
+++ b/branding/logo/wordmark_dark.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/wordmark_gold.svg b/branding/logo/wordmark_gold.svg
new file mode 100644
index 0000000..f9ab581
--- /dev/null
+++ b/branding/logo/wordmark_gold.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/wordmark_light.svg b/branding/logo/wordmark_light.svg
new file mode 100644
index 0000000..23db83f
--- /dev/null
+++ b/branding/logo/wordmark_light.svg
@@ -0,0 +1 @@
+
diff --git a/branding/logo/wordmark_white.svg b/branding/logo/wordmark_white.svg
new file mode 100644
index 0000000..444cedb
--- /dev/null
+++ b/branding/logo/wordmark_white.svg
@@ -0,0 +1 @@
+
diff --git a/branding/templates/avatar_512.png b/branding/templates/avatar_512.png
new file mode 100644
index 0000000..bb59681
Binary files /dev/null and b/branding/templates/avatar_512.png differ
diff --git a/branding/templates/cli_banner.txt b/branding/templates/cli_banner.txt
new file mode 100644
index 0000000..8906e40
--- /dev/null
+++ b/branding/templates/cli_banner.txt
@@ -0,0 +1,6 @@
+ o o SimurgArray {version}
+ oo o oo Thirty birds, one picture.
+ ooooooo
+ ooo IDAT to genotypes, QC, CN, PGx
+ / | \ research use only
+ * o *
diff --git a/branding/templates/featured_template_1200x630.jpg b/branding/templates/featured_template_1200x630.jpg
new file mode 100644
index 0000000..cd56a4f
Binary files /dev/null and b/branding/templates/featured_template_1200x630.jpg differ
diff --git a/branding/templates/readme_banner_1280x320.png b/branding/templates/readme_banner_1280x320.png
new file mode 100644
index 0000000..2416367
Binary files /dev/null and b/branding/templates/readme_banner_1280x320.png differ
diff --git a/branding/templates/report_corner_mark.html b/branding/templates/report_corner_mark.html
new file mode 100644
index 0000000..211d6f8
--- /dev/null
+++ b/branding/templates/report_corner_mark.html
@@ -0,0 +1,6 @@
+
+
+
+ SimurgArray {version} · run {manifest_sha256[:12]}
+
diff --git a/branding/templates/report_corner_mark.svg b/branding/templates/report_corner_mark.svg
new file mode 100644
index 0000000..092c95a
--- /dev/null
+++ b/branding/templates/report_corner_mark.svg
@@ -0,0 +1 @@
+
diff --git a/branding/templates/social_preview_1280x640.png b/branding/templates/social_preview_1280x640.png
new file mode 100644
index 0000000..4189faa
Binary files /dev/null and b/branding/templates/social_preview_1280x640.png differ
diff --git a/scripts/blog/make_featured_images.py b/scripts/blog/make_featured_images.py
index 1c02062..1356df9 100644
--- a/scripts/blog/make_featured_images.py
+++ b/scripts/blog/make_featured_images.py
@@ -1,6 +1,6 @@
"""Featured images for the blog posts (1200 x 630 JPEG, WordPress / social preview size).
-A bird drawn from dots on a BeadChip-like grid: red and green are the two scanner channels, the bird is gold.
+The SimurgArray mark (scripts/brand/simurg_mark.py) on a bead grid: gold bird, red and green channel plumes.
Run with any Python that has Pillow: python3 scripts/blog/make_featured_images.py → blog/images/*.jpg
"""
@@ -8,9 +8,13 @@ from __future__ import annotations
import math
import random
+import sys
from pathlib import Path
-from PIL import Image, ImageDraw, ImageFilter, ImageFont
+from PIL import Image, ImageDraw, ImageFont
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "brand"))
+from simurg_mark import SIMPLE, Grid, art, detailed
W, H, SS = 1200, 630, 2 # output size, supersampling
INDIGO, INDIGO2 = (30, 34, 64), (18, 20, 40)
@@ -28,89 +32,6 @@ POSTS = [
] # fmt: skip
-def _bezier(p0: tuple[float, float], p1: tuple[float, float], p2: tuple[float, float],
- w0: float, w1: float, n: int = 40) -> list[tuple[float, float]]: # fmt: skip
- """Polygon of a tapered quadratic Bezier stroke (a tail plume)."""
- left, right = [], []
- for i in range(n + 1):
- t = i / n
- x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t * t * p2[0]
- y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t * t * p2[1]
- dx = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0])
- dy = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1])
- ln = math.hypot(dx, dy) or 1.0
- hw = (w0 + (w1 - w0) * t) / 2
- left.append((x - dy / ln * hw, y + dx / ln * hw))
- right.append((x + dy / ln * hw, y - dx / ln * hw))
- return left + right[::-1]
-
-
-def bird_mask(w: int, h: int) -> Image.Image:
- """Silhouette of a simurg in flight, facing right: raised swept wings, crest, hooked beak and three long
- plumes ending in peacock eyes, in a w x h box (design grid 520 x 420)."""
- sx, sy = w / 520, h / 420
- m = Image.new("L", (w, h), 0)
- d = ImageDraw.Draw(m)
-
- def p(pts: list[tuple[float, float]]) -> list[tuple[float, float]]:
- return [(x * sx, y * sy) for x, y in pts]
-
- def circle(cx: float, cy: float, r: float) -> None:
- d.ellipse([(cx - r) * sx, (cy - r) * sy, (cx + r) * sx, (cy + r) * sy], fill=255)
-
- body = [(250 + 95 * math.cos(t) * math.cos(-0.35) - 34 * math.sin(t) * math.sin(-0.35) + 70,
- 228 + 95 * math.cos(t) * math.sin(-0.35) + 34 * math.sin(t) * math.cos(-0.35))
- for t in [k * math.pi / 30 for k in range(60)]] # fmt: skip
- d.polygon(p(body), fill=255)
- d.polygon(p([(385, 170), (420, 150), (440, 200), (395, 225)]), fill=255) # neck
- circle(432, 166, 27) # head
- d.polygon(p([(452, 152), (496, 166), (486, 184), (470, 178), (452, 184)]), fill=255) # hooked beak
- for tip in ((372, 118), (392, 108), (414, 112)): # crest
- d.polygon(p(_bezier((425, 150), ((425 + tip[0]) / 2, tip[1] + 18), tip, 12, 3)), fill=255)
- d.polygon(
- p(
- [
- (345, 205),
- (338, 150),
- (312, 92),
- (266, 46),
- (200, 12),
- (222, 48),
- (176, 44),
- (226, 84),
- (170, 92),
- (236, 122),
- (180, 138),
- (250, 162),
- (204, 186),
- (284, 214),
- ]
- ),
- fill=255,
- ) # near wing
- d.polygon(
- p(
- [
- (305, 214),
- (268, 162),
- (214, 124),
- (140, 100),
- (170, 130),
- (124, 138),
- (184, 162),
- (146, 176),
- (220, 196),
- (262, 236),
- ]
- ),
- fill=255,
- ) # far wing
- for c, e, eye in (((150, 250), (22, 300), 20), ((170, 320), (58, 392), 18), ((230, 350), (170, 408), 15)):
- d.polygon(p(_bezier((250, 250), c, e, 30, 8)), fill=255) # plume
- circle(e[0], e[1], eye) # peacock eye
- return m
-
-
def background(rng: random.Random) -> Image.Image:
img = Image.new("RGB", (W * SS, H * SS), INDIGO)
d = ImageDraw.Draw(img)
@@ -129,69 +50,59 @@ def background(rng: random.Random) -> Image.Image:
return img
-def dot_bird(img: Image.Image, x0: int, y0: int, w: int, h: int, step: int, rng: random.Random,
- dim: float = 1.0) -> list[tuple[float, float]]: # fmt: skip
- """Draw the bird as dots; returns the dot centres (full-resolution pixels)."""
- mask = bird_mask(w, h)
- edge = mask.filter(ImageFilter.MinFilter(max(3, (step // 2) | 1)))
+def draw_mark(img: Image.Image, grid: Grid, x: float, y: float, cell: float, dim: float = 1.0,
+ rad: float = 0.4) -> list[tuple[float, float]]: # fmt: skip
+ """Draw a mark grid with its top left at (x, y); returns the dot centres."""
d = ImageDraw.Draw(img)
+ pal = {"gold": GOLD, "red": RED, "green": GREEN}
pts = []
- for yy in range(step // 2, h, step):
- for xx in range(step // 2, w, step):
- if mask.getpixel((xx, yy)) < 128:
- continue
- inner = edge.getpixel((xx, yy)) >= 128
- col = GOLD if inner else (RED if (xx // step + yy // step) % 2 else GREEN)
- if inner and rng.random() < 0.12:
- col = (236, 196, 72)
- c = tuple(int(bg + (cc - bg) * dim) for bg, cc in zip(INDIGO, col, strict=True))
- r = step * (0.36 if inner else 0.30)
- cx, cy = x0 + xx, y0 + yy
- d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=c)
- pts.append((cx, cy))
+ for (c, r), layer in grid.items():
+ col = tuple(int(bg + (cc - bg) * dim) for bg, cc in zip(INDIGO, pal[layer], strict=True))
+ cx, cy = x + (c + 0.5) * cell, y + (r + 0.5) * cell
+ d.ellipse([cx - rad * cell, cy - rad * cell, cx + rad * cell, cy + rad * cell], fill=col)
+ pts.append((cx, cy))
return pts
+def dot(d: ImageDraw.ImageDraw, cx: float, cy: float, r: float, col: tuple[int, int, int], a: float) -> None:
+ c = tuple(int(bg + (cc - bg) * a) for bg, cc in zip(INDIGO, col, strict=True))
+ d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=c)
+
+
def motif(img: Image.Image, kind: str, rng: random.Random) -> None:
d = ImageDraw.Draw(img)
- bx, by, bw, bh = 640 * SS, 90 * SS, 520 * SS, 420 * SS
+ big, _, _ = detailed()
+ small, sc, sr = art(SIMPLE)
if kind == "box": # a closed box, and a stream of dots leaving it to become the bird
- x, y, sz = 590 * SS, 380 * SS, 150 * SS
+ x, y, sz = 600 * SS, 400 * SS, 140 * SS
d.rectangle([x, y, x + sz, y + sz], fill=(12, 13, 26), outline=(70, 74, 110), width=2 * SS)
- sx0, sy0, sx1, sy1 = x + sz - 20 * SS, y + 20 * SS, 790 * SS, 300 * SS
- for k in range(9):
- f = k / 8
- cx, cy = sx0 + (sx1 - sx0) * f, sy0 + (sy1 - sy0) * f
+ cell = 14 * SS
+ mx, my = 730 * SS, 40 * SS
+ draw_mark(img, big, mx, my, cell)
+ x0, y0, x1, y1 = x + sz - 18 * SS, y + 18 * SS, mx + 9 * cell, my + 24 * cell
+ for k in range(10):
+ f = k / 9
col = GOLD if f > 0.6 else (RED if k % 2 else GREEN)
- a = 0.35 + 0.65 * f
- c = tuple(int(bg + (cc - bg) * a) for bg, cc in zip(INDIGO, col, strict=True))
- r = (2.2 + 2.6 * f) * SS
- d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=c)
- dot_bird(img, bx + 90 * SS, by - 50 * SS, int(bw * 0.82), int(bh * 0.82), 12 * SS, rng)
+ dot(d, x0 + (x1 - x0) * f, y0 + (y1 - y0) * f, (2.2 + 2.4 * f) * SS, col, 0.35 + 0.65 * f)
elif kind == "flock": # smaller dim birds around one bright bird
- for fx, fy, fs, dim in (
- (560, 70, 0.28, 0.35),
- (600, 430, 0.24, 0.3),
- (1030, 470, 0.22, 0.3),
- (1060, 40, 0.2, 0.28),
- ):
- dot_bird(img, fx * SS, fy * SS, int(bw * fs), int(bh * fs), 6 * SS, rng, dim)
- dot_bird(img, bx + 40 * SS, by + 20 * SS, int(bw * 0.8), int(bh * 0.8), 12 * SS, rng)
+ for fx, fy, cell, dim in ((575, 60, 4.2, 0.4), (600, 440, 3.6, 0.34), (1070, 470, 3.4, 0.34),
+ (1080, 40, 3.2, 0.3)): # fmt: skip
+ draw_mark(img, big, fx * SS, fy * SS, cell * SS, dim)
+ draw_mark(img, big, 700 * SS, 100 * SS, 13 * SS)
elif kind == "converge": # dots flowing in from the edges
- pts = dot_bird(img, bx, by, bw, bh, 13 * SS, rng)
+ pts = draw_mark(img, big, 690 * SS, 70 * SS, 15 * SS)
for _ in range(90):
tx, ty = rng.choice(pts)
- ang = rng.uniform(math.pi * 0.55, math.pi * 1.45)
- dist = rng.uniform(140, 420) * SS
+ ang = rng.uniform(0, 2 * math.pi)
+ dist = rng.uniform(120, 360) * SS
for k in range(1, 7):
f = k / 7
cx, cy = tx + math.cos(ang) * dist * f, ty + math.sin(ang) * dist * f
- a = 0.55 * (1 - f)
- col = RED if k % 2 else GREEN
- c = tuple(int(bg + (cc - bg) * a) for bg, cc in zip(INDIGO, col, strict=True))
- r = (2.6 - 1.6 * f) * SS
- d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=c)
+ if cx < 620 * SS:
+ continue
+ dot(d, cx, cy, (2.6 - 1.6 * f) * SS, RED if k % 2 else GREEN, 0.5 * (1 - f))
elif kind == "calendar": # a calendar grid under the bird, some weeks done
+ draw_mark(img, big, 740 * SS, 26 * SS, 11 * SS)
x0, y0, cw, ch = 610 * SS, 400 * SS, 62 * SS, 34 * SS
for i in range(14):
cx, cy = x0 + (i % 7) * (cw + 8 * SS), y0 + (i // 7) * (ch + 8 * SS)
@@ -202,20 +113,23 @@ def motif(img: Image.Image, kind: str, rng: random.Random) -> None:
if done:
d.line([(cx + 20 * SS, cy + 18 * SS), (cx + 28 * SS, cy + 25 * SS), (cx + 44 * SS, cy + 10 * SS)],
fill=GOLD, width=3 * SS) # fmt: skip
- dot_bird(img, bx + 40 * SS, 30 * SS, int(bw * 0.72), int(bh * 0.72), 11 * SS, rng)
elif kind == "thirty": # thirty small birds that together make the big one
- dot_bird(img, bx, by, bw, bh, 13 * SS, rng, dim=0.16)
- mask = bird_mask(bw, bh)
- spots: list[tuple[int, int]] = []
- tries = 0
- while len(spots) < 30 and tries < 50000:
- tries += 1
- x, y = rng.randrange(bw), rng.randrange(bh)
- if mask.getpixel((x, y)) > 128 and all((x - a) ** 2 + (y - b) ** 2 > (50 * SS) ** 2 for a, b in spots):
- spots.append((x, y))
- s = 0.15
- for x, y in spots:
- dot_bird(img, bx + x - int(bw * s / 2), by + y - int(bh * s / 2), int(bw * s), int(bh * s), 3 * SS, rng)
+ cell = 15 * SS
+ mx, my = 680 * SS, 70 * SS
+ pts = draw_mark(img, big, mx, my, cell, dim=0.16)
+ scell = 4.2 * SS
+ w, h = sc * scell, sr * scell
+ spots: list[tuple[float, float]] = []
+ order = pts[:]
+ rng.shuffle(order)
+ for md in (60, 54, 48, 44, 40):
+ for x, y in order:
+ if len(spots) >= 30:
+ break
+ if all((x - a) ** 2 + (y - b) ** 2 > (md * SS) ** 2 for a, b in spots):
+ spots.append((x, y))
+ for x, y in spots[:30]:
+ draw_mark(img, small, x - w / 2, y - h / 2, scell)
def text(img: Image.Image, part: str, title: str) -> None:
diff --git a/scripts/brand/make_brand.py b/scripts/brand/make_brand.py
new file mode 100644
index 0000000..7195943
--- /dev/null
+++ b/scripts/brand/make_brand.py
@@ -0,0 +1,452 @@
+"""SimurgArray brand kit: marks, wordmark, lockups, icons, templates and checks → branding/.
+
+Needs Pillow and fontTools (for the wordmark outlines), rsvg-convert and ImageMagick:
+ python -m venv /tmp/b && /tmp/b/bin/pip install pillow fonttools && /tmp/b/bin/python scripts/brand/make_brand.py
+
+The mark is drawn on a dot grid in grid units (capsules and circles tested at each cell centre), so every export is
+the same set of dots: a frontal simurg with raised wings, a three-dot crown and three tail plumes (red channel,
+gold, green channel) ending in peacock eyes.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+from fontTools.pens.svgPathPen import SVGPathPen
+from fontTools.pens.transformPen import TransformPen
+from fontTools.ttLib import TTFont
+from PIL import Image, ImageDraw, ImageFont
+
+ROOT = Path(__file__).resolve().parents[2]
+OUT = ROOT / "branding"
+FONTS = Path("/usr/share/fonts/truetype/noto")
+
+C = {
+ "indigo": "#1E2240", "indigo_deep": "#12142A", "paper": "#F7F5EF", "red": "#C8453A", "green": "#2F8F5B",
+ "gold": "#D4A017", "gold_deep": "#A87A00", "gold_ink": "#8C6500", "muted_dark": "#A3A7C9",
+ "muted_light": "#5A5E7E", "black": "#111111", "white": "#FFFFFF",
+} # fmt: skip
+CHART = {"AA": ("#D55E00", "circle"), "AB": ("#009E73", "triangle"), "BB": ("#0072B2", "square")} # Okabe and Ito
+SCHEMES = { # layer colours per version
+ "dark": {"gold": C["gold"], "red": C["red"], "green": C["green"], "bg": C["indigo"], "text": C["paper"]},
+ "light": {"gold": C["gold_deep"], "red": C["red"], "green": C["green"], "bg": C["paper"], "text": C["indigo"]},
+ "gold": {"gold": C["gold"], "red": C["gold"], "green": C["gold"], "bg": None, "text": C["gold"]},
+ "black": {"gold": C["black"], "red": C["black"], "green": C["black"], "bg": None, "text": C["black"]},
+ "white": {"gold": C["white"], "red": C["white"], "green": C["white"], "bg": None, "text": C["white"]},
+}
+ACCENT = {"dark": C["gold"], "light": C["gold_ink"], "gold": C["gold"], "black": C["black"], "white": C["white"]}
+
+# ---------------------------------------------------------------- the mark (geometry in simurg_mark.py)
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from simurg_mark import MICRO, SIMPLE, Grid, art, detailed # noqa: E402
+
+MARKS = {"mark": detailed(), "mark_simple": art(SIMPLE), "mark_micro": art(MICRO)}
+
+
+def mark_svg_body(grid: Grid, scheme: str, x0: float = 0, y0: float = 0, cell: float = 1.0, rad: float = 0.4) -> str:
+ col = SCHEMES[scheme]
+ out = []
+ for layer in ("gold", "red", "green"):
+ cells = sorted((rc for rc, lay in grid.items() if lay == layer), key=lambda k: (k[1], k[0]))
+ dots = [f''
+ for c, r in cells] # fmt: skip
+ if dots:
+ out.append(f'' + "".join(dots) + "")
+ return "".join(out)
+
+
+def svg(w: float, h: float, body: str, bg: str | None = None, title: str = "SimurgArray") -> str:
+ rect = f'' if bg else ""
+ return (f'\n') # fmt: skip
+
+
+# ---------------------------------------------------------------- the wordmark (outlined Noto Sans SemiBold)
+
+
+class Wordmark:
+ def __init__(self, path: Path) -> None:
+ self.font = TTFont(path)
+ self.gs = self.font.getGlyphSet()
+ self.cmap = self.font.getBestCmap()
+ self.upm = self.font["head"].unitsPerEm
+ self.hmtx = self.font["hmtx"]
+
+ def paths(self, text: str, size: float, x: float, baseline: float, tracking: float = 0.0) -> tuple[str, float]:
+ """SVG path data for ``text`` at ``size`` px, starting at x; returns (d, advance)."""
+ s = size / self.upm
+ ds, pen_x = [], x
+ for ch in text:
+ gname = self.cmap[ord(ch)]
+ pen = SVGPathPen(self.gs)
+ self.gs[gname].draw(TransformPen(pen, (s, 0, 0, -s, pen_x, baseline)))
+ ds.append(pen.getCommands())
+ pen_x += self.hmtx[gname][0] * s + tracking * size
+ return " ".join(ds), pen_x - x - tracking * size
+
+ def cap_height(self, size: float) -> float:
+ return float(getattr(self.font["OS/2"], "sCapHeight", 714)) * size / self.upm
+
+
+WM = Wordmark(FONTS / "NotoSans-SemiBold.ttf")
+
+
+def wordmark_body(scheme: str, x: float, baseline: float, size: float) -> tuple[str, float]:
+ d1, w1 = WM.paths("Simurg", size, x, baseline, tracking=0.005)
+ d2, w2 = WM.paths("Array", size, x + w1 + size * 0.03, baseline, tracking=0.005)
+ body = f''
+ return body, w1 + size * 0.03 + w2
+
+
+# ---------------------------------------------------------------- files
+
+
+def write(path: Path, text: str) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text)
+ return path
+
+
+def png(svg_path: Path, out: Path, width: int) -> None:
+ out.parent.mkdir(parents=True, exist_ok=True)
+ subprocess.run(["rsvg-convert", "-w", str(width), "-o", str(out), str(svg_path)], check=True)
+
+
+def logos() -> None:
+ for name, (g, cols, rows) in MARKS.items():
+ pad = 1
+ for scheme in SCHEMES:
+ p = write(OUT / "logo" / f"{name}_{scheme}.svg",
+ svg(cols + 2 * pad, rows + 2 * pad, mark_svg_body(g, scheme, pad, pad))) # fmt: skip
+ png(p, OUT / "logo" / "png" / f"{name}_{scheme}.png", 512 if name == "mark" else 256)
+ size = 100.0
+ for scheme in SCHEMES:
+ body, w = wordmark_body(scheme, 0, size * 0.78, size)
+ p = write(OUT / "logo" / f"wordmark_{scheme}.svg", svg(w + 4, size, body))
+ png(p, OUT / "logo" / "png" / f"wordmark_{scheme}.png", 900)
+ g, cols, rows = MARKS["mark"]
+ for scheme in ("dark", "light", "black", "white"):
+ bg = SCHEMES[scheme]["bg"]
+ # horizontal: mark 120 px high, wordmark cap height ~ 40 % of it
+ cell = 120 / rows
+ ms = mark_svg_body(g, scheme, 40, 40, cell)
+ wsize = 64.0
+ wb, ww = wordmark_body(scheme, 40 + cols * cell + 36, 40 + 60 + WM.cap_height(wsize) / 2, wsize)
+ W, H = 40 + cols * cell + 36 + ww + 48, 200
+ p = write(OUT / "logo" / f"lockup_horizontal_{scheme}.svg", svg(W, H, ms + wb, bg))
+ png(p, OUT / "logo" / "png" / f"lockup_horizontal_{scheme}.png", 1200)
+ # stacked
+ cell = 200 / rows
+ wsize = 60.0
+ _, ww = wordmark_body(scheme, 0, 0, wsize)
+ W = max(cols * cell, ww) + 80
+ ms = mark_svg_body(g, scheme, (W - cols * cell) / 2, 40, cell)
+ wb, _ = wordmark_body(scheme, (W - ww) / 2, 40 + rows * cell + 30 + WM.cap_height(wsize), wsize)
+ p = write(OUT / "logo" / f"lockup_stacked_{scheme}.svg", svg(W, 40 + rows * cell + 30 + 90, ms + wb, bg))
+ png(p, OUT / "logo" / "png" / f"lockup_stacked_{scheme}.png", 800)
+
+
+def icons() -> None:
+ """Rounded indigo tile; micro mark at 16, simple at 32/48, detailed from 180 up."""
+ d = OUT / "icons"
+ for size, name in ((16, "mark_micro"), (32, "mark_simple"), (48, "mark_simple"), (180, "mark"), (512, "mark")):
+ g, cols, rows = MARKS[name]
+ n = max(cols, rows) + (1 if size <= 48 else 3)
+ rx = n * 0.18
+ body = f''
+ body += mark_svg_body(g, "dark", (n - cols) / 2, (n - rows) / 2, 1.0, 0.42 if size <= 48 else 0.4)
+ p = write(d / f"icon_{size}.svg", svg(n, n, body))
+ png(p, d / f"icon_{size}.png", size)
+ subprocess.run(["magick", str(d / "icon_16.png"), str(d / "icon_32.png"), str(d / "icon_48.png"),
+ str(d / "favicon.ico")], check=True) # fmt: skip
+ for f in d.glob("icon_*.svg"):
+ if f.stem not in ("icon_16", "icon_512"):
+ f.unlink()
+
+
+# ---------------------------------------------------------------- raster templates (Pillow)
+
+
+def rgb(h: str) -> tuple[int, int, int]:
+ return int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16)
+
+
+def canvas(w: int, h: int, ss: int = 2) -> Image.Image:
+ img = Image.new("RGB", (w * ss, h * ss), rgb(C["indigo"]))
+ dr = ImageDraw.Draw(img)
+ a, b = rgb(C["indigo"]), rgb(C["indigo_deep"])
+ for y in range(h * ss):
+ t = y / (h * ss)
+ dr.line([(0, y), (w * ss, y)], fill=tuple(int(x + (z - x) * t) for x, z in zip(a, b, strict=True)))
+ step, k = 18 * ss, 0
+ for gy in range(step // 2, h * ss, step): # faint bead grid, alternating channels
+ for gx in range(step // 2, w * ss, step):
+ k += 1
+ col = rgb(C["red"] if (gx // step + gy // step) % 2 else C["green"])
+ c = tuple(int(bg + (cc - bg) * 0.14) for bg, cc in zip(a, col, strict=True))
+ r = 1.6 * ss
+ dr.ellipse([gx - r, gy - r, gx + r, gy + r], fill=c)
+ return img
+
+
+def draw_mark(img: Image.Image, grid: Grid, x: float, y: float, cell: float, scheme: str = "dark",
+ rad: float = 0.4) -> None: # fmt: skip
+ dr = ImageDraw.Draw(img)
+ col = SCHEMES[scheme]
+ for (c, r), layer in grid.items():
+ cx, cy = x + (c + 0.5) * cell, y + (r + 0.5) * cell
+ dr.ellipse([cx - rad * cell, cy - rad * cell, cx + rad * cell, cy + rad * cell], fill=rgb(col[layer]))
+
+
+def font(name: str, px: float) -> ImageFont.FreeTypeFont:
+ return ImageFont.truetype(str(FONTS / name), int(px))
+
+
+def text_wordmark(img: Image.Image, x: float, y: float, px: float) -> float:
+ dr = ImageDraw.Draw(img)
+ f = font("NotoSans-SemiBold.ttf", px)
+ dr.text((x, y), "Simurg", font=f, fill=rgb(C["paper"]))
+ w = dr.textlength("Simurg", font=f) + px * 0.03
+ dr.text((x + w, y), "Array", font=f, fill=rgb(C["gold"]))
+ return w + dr.textlength("Array", font=f)
+
+
+def save(img: Image.Image, w: int, h: int, path: Path, quality: int = 86) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ img = img.resize((w, h), Image.Resampling.LANCZOS)
+ if path.suffix == ".jpg":
+ img.save(path, "JPEG", quality=quality, optimize=True, progressive=True)
+ else:
+ img.save(path, "PNG", optimize=True)
+
+
+def templates() -> None:
+ ss = 2
+ g, cols, rows = MARKS["mark"]
+ t = OUT / "templates"
+ # README banner 1280 x 320
+ img = canvas(1280, 320, ss)
+ cell = 230 * ss / rows
+ draw_mark(img, g, 70 * ss, 45 * ss, cell)
+ x = 70 * ss + cols * cell + 50 * ss
+ text_wordmark(img, x, 88 * ss, 76 * ss)
+ ImageDraw.Draw(img).text((x + 4 * ss, 196 * ss), "Thirty birds, one picture.", font=font("NotoSans-Regular.ttf",
+ 30 * ss), fill=rgb(C["muted_dark"])) # fmt: skip
+ save(img, 1280, 320, t / "readme_banner_1280x320.png")
+ # repository social preview 1280 x 640
+ img = canvas(1280, 640, ss)
+ cell = 380 * ss / rows
+ draw_mark(img, g, 90 * ss, 110 * ss, cell)
+ x = 90 * ss + cols * cell + 60 * ss
+ text_wordmark(img, x, 200 * ss, 84 * ss)
+ dr = ImageDraw.Draw(img)
+ dr.text((x + 4 * ss, 320 * ss), "Thirty birds, one picture.", font=font("NotoSans-Regular.ttf", 34 * ss),
+ fill=rgb(C["muted_dark"])) # fmt: skip
+ dr.text((x + 4 * ss, 400 * ss), "Open Infinium array analysis:\nIDAT to genotypes, QC, CN and PGx",
+ font=font("NotoSansMono-Regular.ttf", 24 * ss), fill=rgb(C["gold"]), spacing=10 * ss) # fmt: skip
+ save(img, 1280, 640, t / "social_preview_1280x640.png")
+ # avatar 512, safe inside a circle (mark within 70 % of the diameter)
+ img = Image.new("RGB", (512 * ss, 512 * ss), rgb(C["indigo"]))
+ cell = 512 * ss * 0.62 / max(cols, rows)
+ draw_mark(img, g, (512 * ss - cols * cell) / 2, (512 * ss - rows * cell) / 2 + 6 * ss, cell)
+ save(img, 512, 512, t / "avatar_512.png")
+ # WordPress featured image template: title area left, mark right
+ img = canvas(1200, 630, ss)
+ cell = 400 * ss / rows
+ draw_mark(img, g, 1200 * ss - cols * cell - 70 * ss, 100 * ss, cell)
+ dr = ImageDraw.Draw(img)
+ dr.text((70 * ss, 88 * ss), "PART N", font=font("NotoSansMono-Regular.ttf", 26 * ss), fill=rgb(C["gold"]))
+ dr.multiline_text((70 * ss, 140 * ss), "Post title in\ntwo or three lines", font=font("NotoSans-Bold.ttf", 58 * ss),
+ fill=rgb(C["paper"]), spacing=14 * ss) # fmt: skip
+ footer(img, 70 * ss, 530 * ss, ss)
+ save(img, 1200, 630, t / "featured_template_1200x630.jpg")
+
+
+def footer(img: Image.Image, x: float, y: float, ss: int) -> None:
+ dr = ImageDraw.Draw(img)
+ for i, k in enumerate(("red", "green", "gold")):
+ dr.ellipse([x + i * 18 * ss, y + 10 * ss, x + i * 18 * ss + 12 * ss, y + 22 * ss], fill=rgb(C[k]))
+ f = font("NotoSans-Regular.ttf", 24 * ss)
+ dr.text((x + 66 * ss, y), "SimurgArray", font=f, fill=rgb(C["paper"]))
+ dr.text((x + 66 * ss + dr.textlength("SimurgArray ", font=f), y), "Thirty birds, one picture.", font=f,
+ fill=rgb(C["muted_dark"])) # fmt: skip
+
+
+def report_mark() -> None:
+ g, cols, rows = MARKS["mark_simple"]
+ body = mark_svg_body(g, "dark", 1, 1)
+ write(OUT / "templates" / "report_corner_mark.svg", svg(cols + 2, rows + 2, body))
+ style = f"display:flex;align-items:center;gap:8px;font:12px 'Noto Sans Mono',monospace;color:{C['muted_dark']}"
+ write(OUT / "templates" / "report_corner_mark.html", f"""
+
+
+ SimurgArray {{version}} · run {{manifest_sha256[:12]}}
+
+""") # fmt: skip
+
+
+CLI_BANNER = r"""
+ o o SimurgArray {version}
+ oo o oo Thirty birds, one picture.
+ ooooooo
+ ooo IDAT to genotypes, QC, CN, PGx
+ / | \ research use only
+ * o *
+"""
+
+
+# ---------------------------------------------------------------- checks
+
+
+def _lin(c: float) -> float:
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
+
+
+def _unlin(c: float) -> int:
+ c = max(0.0, min(1.0, c))
+ return round(255 * (12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055))
+
+
+def contrast(a: str, b: str) -> float:
+ def lum(h: str) -> float:
+ r, g, bb = (_lin(v / 255) for v in rgb(h))
+ return 0.2126 * r + 0.7152 * g + 0.0722 * bb
+
+ la, lb = sorted((lum(a), lum(b)), reverse=True)
+ return (la + 0.05) / (lb + 0.05)
+
+
+MACHADO = { # Machado, Oliveira and Fernandes 2009, severity 1.0 (linear RGB)
+ "protanopia": ((0.152286, 1.052583, -0.204868), (0.114503, 0.786281, 0.099216), (-0.003882, -0.048116, 1.051998)),
+ "deuteranopia": ((0.367322, 0.860646, -0.227968), (0.280085, 0.672501, 0.047413),
+ (-0.011820, 0.042940, 0.968881)),
+} # fmt: skip
+
+
+def simulate(img: Image.Image, kind: str) -> Image.Image:
+ m = MACHADO[kind]
+ lut = [_lin(i / 255) for i in range(256)]
+ px = img.convert("RGB").load()
+ out = img.convert("RGB").copy()
+ po = out.load()
+ cache: dict[tuple[int, int, int], tuple[int, int, int]] = {}
+ for y in range(img.height):
+ for x in range(img.width):
+ v = px[x, y]
+ if v not in cache:
+ r, g, b = (lut[c] for c in v)
+ cache[v] = tuple(_unlin(row[0] * r + row[1] * g + row[2] * b) for row in m) # type: ignore[assignment]
+ po[x, y] = cache[v]
+ return out
+
+
+def chart_example(ss: int = 2) -> Image.Image:
+ """A genotype cluster plot in the chart palette: colour plus marker shape."""
+ import random
+
+ rng = random.Random(7)
+ w, h = 420, 300
+ img = Image.new("RGB", (w * ss, h * ss), rgb(C["paper"]))
+ dr = ImageDraw.Draw(img)
+ dr.line([(40 * ss, 260 * ss), (400 * ss, 260 * ss)], fill=rgb(C["muted_light"]), width=ss)
+ dr.line([(40 * ss, 20 * ss), (40 * ss, 260 * ss)], fill=rgb(C["muted_light"]), width=ss)
+ for gt, cx in (("AA", 90), ("AB", 220), ("BB", 350)):
+ col, shape = CHART[gt]
+ for _ in range(40):
+ x, y = (cx + rng.gauss(0, 14)) * ss, (140 + rng.gauss(0, 30)) * ss
+ r = 5 * ss
+ if shape == "circle":
+ dr.ellipse([x - r, y - r, x + r, y + r], fill=rgb(col))
+ elif shape == "square":
+ dr.rectangle([x - r * 0.9, y - r * 0.9, x + r * 0.9, y + r * 0.9], fill=rgb(col))
+ else:
+ dr.polygon([(x, y - r * 1.1), (x - r, y + r * 0.8), (x + r, y + r * 0.8)], fill=rgb(col))
+ dr.text((cx * ss - 12 * ss, 268 * ss), gt, font=font("NotoSansMono-Regular.ttf", 16 * ss),
+ fill=rgb(C["indigo"])) # fmt: skip
+ return img.resize((w, h), Image.Resampling.LANCZOS)
+
+
+def checks() -> list[tuple[str, str, str, float]]:
+ d = OUT / "checks"
+ d.mkdir(parents=True, exist_ok=True)
+ g, cols, rows = MARKS["mark"]
+ logo = Image.new("RGB", (cols * 12 + 24, rows * 12 + 24), rgb(C["indigo"]))
+ draw_mark(logo, g, 12, 12, 12)
+ chart = chart_example()
+ panels = [("original", logo, chart)] + [(k, simulate(logo, k), simulate(chart, k)) for k in MACHADO]
+ pw = logo.width + chart.width + 30
+ ph = max(logo.height, chart.height) + 50
+ sheet = Image.new("RGB", (pw, ph * 3), rgb(C["paper"]))
+ dr = ImageDraw.Draw(sheet)
+ for i, (name, lg, ch) in enumerate(panels):
+ y = i * ph
+ dr.text((10, y + 8), name, font=font("NotoSansMono-Regular.ttf", 18), fill=rgb(C["indigo"]))
+ sheet.paste(lg, (10, y + 36))
+ sheet.paste(ch, (logo.width + 20, y + 36))
+ sheet.save(d / "colour_vision_check.png", optimize=True)
+ # small sizes side by side
+ row = Image.new("RGB", (520, 90), rgb(C["paper"]))
+ x = 10
+ for s in (16, 32, 48, 180):
+ ic = Image.open(OUT / "icons" / f"icon_{s}.png").convert("RGB")
+ if s == 180:
+ ic = ic.resize((72, 72), Image.Resampling.LANCZOS)
+ row.paste(ic, (x, 10))
+ x += ic.width + 30
+ row.save(d / "icon_sizes.png", optimize=True)
+ pairs = [("paper", "indigo", "text on dark"), ("muted_dark", "indigo", "secondary text on dark"),
+ ("gold", "indigo", "accent text on dark"), ("indigo", "paper", "text on light"),
+ ("muted_light", "paper", "secondary text on light"), ("gold_ink", "paper", "accent text on light"),
+ ("red", "indigo", "graphics on dark"), ("green", "indigo", "graphics on dark"),
+ ("gold_deep", "paper", "graphics on light"), ("red", "paper", "graphics on light"),
+ ("green", "paper", "graphics on light"), ("gold", "paper", "never for text or small marks")] # fmt: skip
+ return [(a, b, use, contrast(C[a], C[b])) for a, b, use in pairs]
+
+
+def family(featured: list[Path]) -> None:
+ """Featured images and templates side by side (acceptance check 4)."""
+ tiles = [Image.open(p).convert("RGB") for p in featured] + [
+ Image.open(OUT / "templates" / "featured_template_1200x630.jpg").convert("RGB"),
+ Image.open(OUT / "templates" / "social_preview_1280x640.png").convert("RGB"),
+ Image.open(OUT / "templates" / "readme_banner_1280x320.png").convert("RGB"),
+ Image.open(OUT / "templates" / "avatar_512.png").convert("RGB"),
+ ] # fmt: skip
+ th = 200
+ tiles = [t.resize((int(t.width * th / t.height), th), Image.Resampling.LANCZOS) for t in tiles]
+ per_row, gap = 3, 16
+ rows_ = [tiles[i : i + per_row] for i in range(0, len(tiles), per_row)]
+ W = max(sum(t.width for t in r) + gap * (len(r) + 1) for r in rows_)
+ sheet = Image.new("RGB", (W, len(rows_) * (th + gap) + gap), rgb(C["paper"]))
+ y = gap
+ for r in rows_:
+ x = gap
+ for t in r:
+ sheet.paste(t, (x, y))
+ x += t.width + gap
+ y += th + gap
+ sheet.save(OUT / "checks" / "family.jpg", quality=76, optimize=True)
+
+
+def main() -> None:
+ logos()
+ icons()
+ templates()
+ report_mark()
+ write(OUT / "templates" / "cli_banner.txt", CLI_BANNER.lstrip("\n"))
+ table = checks()
+ featured = sorted((ROOT / "blog" / "images").glob("*.jpg"))
+ family(featured)
+ lines = ["| colour | on | use | contrast | AA text (4.5) | graphics (3.0) |", "|---|---|---|---|---|---|"]
+ for a, b, use, cr in table:
+ lines.append(f"| {a} {C[a]} | {b} {C[b]} | {use} | {cr:.2f} | {'yes' if cr >= 4.5 else 'no'} | "
+ f"{'yes' if cr >= 3 else 'no'} |") # fmt: skip
+ write(OUT / "checks" / "contrast.md", "\n".join(lines) + "\n")
+ print("\n".join(lines))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/brand/simurg_mark.py b/scripts/brand/simurg_mark.py
new file mode 100644
index 0000000..efb2ee1
--- /dev/null
+++ b/scripts/brand/simurg_mark.py
@@ -0,0 +1,86 @@
+"""Geometry of the SimurgArray mark on its dot grid (no dependencies beyond the standard library).
+
+A frontal simurg with raised wings (three feathers each), a three-dot crown and three tail plumes: red channel,
+gold, green channel, each ending in a peacock eye. Shapes are capsules, circles and an ellipse in grid units; a cell
+belongs to the first shape that contains its centre. ``SIMPLE`` and ``MICRO`` are hand-drawn small versions.
+"""
+
+from __future__ import annotations
+
+import math
+from typing import Any
+
+
+def _seg(px: float, py: float, a: tuple[float, float], b: tuple[float, float]) -> float:
+ dx, dy = b[0] - a[0], b[1] - a[1]
+ t = max(0.0, min(1.0, ((px - a[0]) * dx + (py - a[1]) * dy) / ((dx * dx + dy * dy) or 1.0)))
+ return math.hypot(px - a[0] - t * dx, py - a[1] - t * dy)
+
+
+CX = 15.5
+_WING = [([(13.6, 9.6), (8, 5.2), (1.8, 1.4)], 1.25), ([(13.4, 11), (7.5, 8.4), (1.2, 6.6)], 1.25),
+ ([(13.4, 12.6), (8, 12), (2.6, 11.8)], 1.2)] # fmt: skip
+SHAPES: list[tuple[str, Any, str]] = []
+for pts, r in _WING:
+ SHAPES.append(("cap", (pts, r), "gold"))
+ SHAPES.append(("cap", ([(2 * CX - x, y) for x, y in pts], r), "gold"))
+SHAPES += [
+ ("circ", (CX, 4.4, 2.3), "gold"), ("cap", ([(CX, 6), (CX, 8)], 1.3), "gold"), # head, neck
+ ("circ", (CX, 1.2, 0.62), "gold"), ("circ", (13.6, 1.9, 0.62), "gold"), ("circ", (17.4, 1.9, 0.62), "gold"),
+ ("ell", (CX, 12.5, 2.9, 5.4), "gold"), # body
+ ("cap", ([(14.8, 17), (12.6, 20.5), (10.4, 23.2)], 0.7), "red"), ("circ", (10, 24.3, 1.6), "red"),
+ ("cap", ([(CX, 17.5), (CX, 23.2)], 0.7), "gold"), ("circ", (CX, 24.6, 1.6), "gold"),
+ ("cap", ([(16.2, 17), (18.4, 20.5), (20.6, 23.2)], 0.7), "green"), ("circ", (21, 24.3, 1.6), "green"),
+] # fmt: skip
+DETAILED_SIZE = (31, 27)
+SIMPLE = """
+X.........X
+XX.......XX
+.XX..X..XX.
+..XXXXXXX..
+XXXXXXXXXXX
+...XXXXX...
+....XXX....
+...R.X.G...
+..R..X..G..
+.RR.XXX.GG.
+"""
+MICRO = """
+X.....X
+XX.X.XX
+.XXXXX.
+..XXX..
+...X...
+.R.X.G.
+R..X..G
+"""
+Grid = dict[tuple[int, int], str]
+
+
+def _inside(x: float, y: float, kind: str, p: Any) -> bool:
+ if kind == "circ":
+ return math.hypot(x - p[0], y - p[1]) <= p[2]
+ if kind == "cap":
+ pts, r = p
+ return min(_seg(x, y, pts[i], pts[i + 1]) for i in range(len(pts) - 1)) <= r
+ cx, cy, rx, ry = p
+ return ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1
+
+
+def detailed() -> tuple[Grid, int, int]:
+ cols, rows = DETAILED_SIZE
+ g: Grid = {}
+ for r in range(rows):
+ for c in range(cols):
+ for kind, p, layer in SHAPES:
+ if _inside(c + 0.5, r + 0.5, kind, p):
+ g[(c, r)] = layer
+ break
+ return g, cols, rows
+
+
+def art(s: str) -> tuple[Grid, int, int]:
+ lines = s.strip().splitlines()
+ g = {(c, r): {"X": "gold", "R": "red", "G": "green"}[ch] for r, ln in enumerate(lines) for c, ch in enumerate(ln)
+ if ch != "."} # fmt: skip
+ return g, len(lines[0]), len(lines)