Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:
- name: Validate issue forms and Markdown links
run: ruby scripts/validate-governance.rb

- name: Validate ByteFolk brand assets
run: ruby scripts/validate-brand-assets.rb

- name: Check whitespace
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
Expand Down
49 changes: 49 additions & 0 deletions brand/bytefolk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# ByteFolk brand mark

The ByteFolk organization mark is the minimal one-color **horned B** approved in
[issue #18](https://github.com/bytefolk/.github/issues/18). It combines a capital `B`,
two modular horns, and an extended lower bowl that hints at the digital-employee mascot without
turning the logo into an illustration.

## Assets

| File | Use |
| --- | --- |
| `symbol.svg` | Primary mark on white or very light surfaces |
| `symbol-reversed.svg` | White mark on black or dark surfaces |
| `lockup.svg` | Horizontal symbol and `ByteFolk` wordmark |
| `avatar-1024.png` | Exact opaque upload artifact for the GitHub organization profile |

The full-body mascot is a separate expressive brand character. It must not replace the symbol in
organization avatars, favicons, repository ownership marks, or compact product navigation.

## Usage

- Primary ink: `#141414`.
- Reversed ink: `#FFFFFF`.
- Keep clear space of at least one horn-stem width around the symbol.
- Keep every visible avatar pixel (any grayscale value below 250) inside the 496 px-radius circle
centered at `(511.5, 511.5)` on the 1024 px canvas. This leaves 16 px inside GitHub's 512 px
circular crop.
- Use the standalone symbol at 16 px or larger.
- Use the horizontal lockup at 120 px wide or larger.
- Upload `avatar-1024.png` without recropping or recoloring it. The image already includes padding
and a validated circular safe area for GitHub's presentation.
- On dark surfaces, use `symbol-reversed.svg`; do not place the primary black mark directly on a
dark background.

Do not add gradients, shadows, outlines, background frames, eyes, nostrils, mane tiles, extra
colors, text inside the mark, or product-specific decoration. Do not stretch, rotate, crop, or
rearrange the horns and B geometry.

## Reproduce the GitHub avatar

ImageMagick is required for deterministic raster export:

```bash
brand/bytefolk/render-avatar.sh
brand/bytefolk/render-avatar.sh --check
```

The first command rewrites `avatar-1024.png` from `symbol.svg`. The second renders to a temporary
file and verifies byte-for-byte equality with the tracked artifact.
Binary file added brand/bytefolk/avatar-1024.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions brand/bytefolk/lockup.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions brand/bytefolk/render-avatar.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail

asset_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source_svg="$asset_dir/symbol.svg"
tracked_png="$asset_dir/avatar-1024.png"
mode="${1:-write}"

if ! command -v magick >/dev/null 2>&1; then
echo "ImageMagick 'magick' is required to render the ByteFolk avatar." >&2
exit 1
fi

render() {
local destination="$1"
magick \
-background '#ffffff' \
"$source_svg" \
-filter box \
-resize '1024x1024!' \
-alpha remove \
-alpha off \
-colorspace sRGB \
-type TrueColor \
-strip \
-define png:exclude-chunks=date,time \
"PNG24:$destination"
}

case "$mode" in
write)
render "$tracked_png"
echo "Rendered $tracked_png"
;;
--check)
candidate="$(mktemp /tmp/bytefolk-avatar-check.XXXXXX)"
trap 'rm -f -- "$candidate"' EXIT
render "$candidate"
if ! cmp -s "$candidate" "$tracked_png"; then
echo "avatar-1024.png is stale; run brand/bytefolk/render-avatar.sh" >&2
exit 1
fi
echo "ByteFolk avatar matches symbol.svg"
;;
*)
echo "Usage: brand/bytefolk/render-avatar.sh [write|--check]" >&2
exit 2
;;
esac
10 changes: 10 additions & 0 deletions brand/bytefolk/symbol-reversed.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions brand/bytefolk/symbol.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
205 changes: 205 additions & 0 deletions scripts/validate-brand-assets.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# frozen_string_literal: true

require "pathname"
require "rexml/document"
require "zlib"

ROOT = Pathname.new(File.expand_path("..", __dir__)).freeze
ASSET_DIR = ROOT.join("brand", "bytefolk").freeze
SVG_NAMES = %w[symbol.svg symbol-reversed.svg lockup.svg].freeze
PROHIBITED_ELEMENTS = %w[filter foreignObject image linearGradient radialGradient script].freeze
CIRCULAR_SAFETY_MARGIN = 16.0
INK_THRESHOLD = 250

errors = []
circular_metrics = nil
geometry_signatures = {}

SVG_NAMES.each do |name|
path = ASSET_DIR.join(name)
unless path.file?
errors << "#{path.relative_path_from(ROOT)}: missing"
next
end

begin
document = REXML::Document.new(path.read)
rescue REXML::ParseException => e
errors << "#{path.relative_path_from(ROOT)}: invalid XML: #{e.message.lines.first.strip}"
next
end

root = document.root
unless root&.name == "svg"
errors << "#{path.relative_path_from(ROOT)}: root element must be svg"
next
end
errors << "#{path.relative_path_from(ROOT)}: viewBox must be present" if root.attributes["viewBox"].to_s.empty?
%w[width height].each do |attribute|
errors << "#{path.relative_path_from(ROOT)}: root #{attribute} is prohibited" if root.attributes[attribute]
end

REXML::XPath.each(document, "//*") do |element|
errors << "#{path.relative_path_from(ROOT)}: <#{element.name}> is prohibited" if PROHIBITED_ELEMENTS.include?(element.name)
element.attributes.each_attribute do |attribute|
value = attribute.value.to_s
next if attribute.expanded_name == "xmlns" && value == "http://www.w3.org/2000/svg"

if value.match?(/(?:https?:|data:|javascript:)/i)
errors << "#{path.relative_path_from(ROOT)}: external or executable attribute is prohibited"
end
end
end

symbol_group = REXML::XPath.first(document, "//*[starts-with(@id, 'bytefolk-symbol')]")
if symbol_group
child_geometry = symbol_group.elements.map do |element|
attributes = element.attributes.map { |key, value| [key, value] }.sort
[element.name, attributes]
end
geometry_signatures[name] = [symbol_group.attributes["transform"].to_s, child_geometry]
else
errors << "#{path.relative_path_from(ROOT)}: ByteFolk symbol group is missing"
end
end

if geometry_signatures.length == SVG_NAMES.length && geometry_signatures.values.uniq.length != 1
errors << "ByteFolk primary, reversed, and lockup symbol geometry must remain identical"
end

png_path = ASSET_DIR.join("avatar-1024.png")
if !png_path.file?
errors << "#{png_path.relative_path_from(ROOT)}: missing"
else
bytes = png_path.binread
signature = "\x89PNG\r\n\x1A\n".b
if bytes.byteslice(0, 8) != signature
errors << "#{png_path.relative_path_from(ROOT)}: invalid PNG signature"
else
offset = 8
ihdr = nil
idat = +"".b
while offset + 12 <= bytes.bytesize
length = bytes.byteslice(offset, 4).unpack1("N")
type = bytes.byteslice(offset + 4, 4)
data = bytes.byteslice(offset + 8, length)
break unless data && offset + 12 + length <= bytes.bytesize

ihdr = data if type == "IHDR"
idat << data if type == "IDAT"
offset += 12 + length
break if type == "IEND"
end

if !ihdr || ihdr.bytesize != 13
errors << "#{png_path.relative_path_from(ROOT)}: missing or invalid IHDR"
else
width, height, bit_depth, color_type, compression, filter_method, interlace = ihdr.unpack("NNCCCCC")
errors << "#{png_path.relative_path_from(ROOT)}: expected 1024x1024, got #{width}x#{height}" unless width == 1024 && height == 1024
errors << "#{png_path.relative_path_from(ROOT)}: expected 8-bit RGB without alpha" unless bit_depth == 8 && color_type == 2
errors << "#{png_path.relative_path_from(ROOT)}: unsupported PNG encoding" unless compression.zero? && filter_method.zero? && interlace.zero?

if errors.empty?
raw = Zlib::Inflate.inflate(idat)
stride = width * 3
expected_size = height * (stride + 1)
errors << "#{png_path.relative_path_from(ROOT)}: decompressed size mismatch" unless raw.bytesize == expected_size

if raw.bytesize == expected_size
prior = Array.new(stride, 0)
pixels = []
cursor = 0
min_x = width
min_y = height
max_x = -1
max_y = -1
center_x = (width - 1) / 2.0
center_y = (height - 1) / 2.0
safe_radius = ([width, height].min / 2.0) - CIRCULAR_SAFETY_MARGIN
max_ink_radius = 0.0
outside_circle_ink = 0

height.times do |y|
filter = raw.getbyte(cursor)
cursor += 1
encoded = raw.byteslice(cursor, stride).bytes
cursor += stride
row = Array.new(stride, 0)

encoded.each_index do |index|
left = index >= 3 ? row[index - 3] : 0
up = prior[index]
upper_left = index >= 3 ? prior[index - 3] : 0
value = case filter
when 0 then encoded[index]
when 1 then encoded[index] + left
when 2 then encoded[index] + up
when 3 then encoded[index] + ((left + up) / 2)
when 4
estimate = left + up - upper_left
distances = [(estimate - left).abs, (estimate - up).abs, (estimate - upper_left).abs]
encoded[index] + [left, up, upper_left][distances.index(distances.min)]
else
errors << "#{png_path.relative_path_from(ROOT)}: unsupported row filter #{filter}"
encoded[index]
end
row[index] = value & 0xff
end

row.each_slice(3).with_index do |(red, green, blue), x|
unless red == green && green == blue
errors << "#{png_path.relative_path_from(ROOT)}: avatar must remain grayscale"
break
end
next if red >= INK_THRESHOLD

min_x = [min_x, x].min
min_y = [min_y, y].min
max_x = [max_x, x].max
max_y = [max_y, y].max
ink_radius = Math.hypot(x - center_x, y - center_y)
max_ink_radius = [max_ink_radius, ink_radius].max
outside_circle_ink += 1 if ink_radius > safe_radius
end
pixels.concat(row)
prior = row
end

darkest = pixels.min
errors << "#{png_path.relative_path_from(ROOT)}: primary ink is not #141414" unless darkest == 20
margins = [min_x, min_y, width - 1 - max_x, height - 1 - max_y]
if max_x.negative? || margins.any? { |margin| margin < 100 }
errors << "#{png_path.relative_path_from(ROOT)}: insufficient rectangular padding #{margins.inspect}"
end
if outside_circle_ink.positive?
errors << format(
"%s: %d ink pixels outside centered circular safe area (center=%.1f,%.1f radius=%.1fpx; max=%.1fpx)",
png_path.relative_path_from(ROOT),
outside_circle_ink,
center_x,
center_y,
safe_radius,
max_ink_radius
)
else
circular_metrics = format(
"outside-circle ink=0; center=(%.1f,%.1f); safe radius=%.1fpx; max ink radius=%.1fpx",
center_x,
center_y,
safe_radius,
max_ink_radius
)
end
end
end
end
end
end

if errors.empty?
puts "Validated #{SVG_NAMES.length} ByteFolk SVGs and one opaque 1024x1024 RGB avatar."
puts "Circular crop: #{circular_metrics}."
else
warn errors.join("\n")
exit 1
end