diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index fd3758f..6056212 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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 }} diff --git a/brand/bytefolk/README.md b/brand/bytefolk/README.md new file mode 100644 index 0000000..439b86f --- /dev/null +++ b/brand/bytefolk/README.md @@ -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. diff --git a/brand/bytefolk/avatar-1024.png b/brand/bytefolk/avatar-1024.png new file mode 100644 index 0000000..82e530b Binary files /dev/null and b/brand/bytefolk/avatar-1024.png differ diff --git a/brand/bytefolk/lockup.svg b/brand/bytefolk/lockup.svg new file mode 100644 index 0000000..3ade989 --- /dev/null +++ b/brand/bytefolk/lockup.svg @@ -0,0 +1,20 @@ + + ByteFolk horizontal logo + The minimal ByteFolk horned B symbol paired with the ByteFolk wordmark. + + + + + + + + ByteFolk + diff --git a/brand/bytefolk/render-avatar.sh b/brand/bytefolk/render-avatar.sh new file mode 100755 index 0000000..511a90d --- /dev/null +++ b/brand/bytefolk/render-avatar.sh @@ -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 diff --git a/brand/bytefolk/symbol-reversed.svg b/brand/bytefolk/symbol-reversed.svg new file mode 100644 index 0000000..94788e3 --- /dev/null +++ b/brand/bytefolk/symbol-reversed.svg @@ -0,0 +1,10 @@ + + ByteFolk reversed symbol + A white version of the minimal ByteFolk symbol for dark backgrounds. + + + + + + + diff --git a/brand/bytefolk/symbol.svg b/brand/bytefolk/symbol.svg new file mode 100644 index 0000000..a522051 --- /dev/null +++ b/brand/bytefolk/symbol.svg @@ -0,0 +1,10 @@ + + ByteFolk symbol + A minimal one-color capital B with two modular horns and an extended lower bowl. + + + + + + + diff --git a/scripts/validate-brand-assets.rb b/scripts/validate-brand-assets.rb new file mode 100644 index 0000000..beda490 --- /dev/null +++ b/scripts/validate-brand-assets.rb @@ -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