aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md22
-rw-r--r--cli/LICENSE21
-rw-r--r--cli/README.md118
-rwxr-xr-xcli/test.sh200
-rwxr-xr-xcli/ugi660
-rw-r--r--fonts/KawkabMono-Bold.woff2bin0 -> 102704 bytes
-rw-r--r--fonts/KawkabMono-Regular.woff2bin0 -> 106884 bytes
-rw-r--r--spec/LICENSE21
-rw-r--r--spec/README.md894
-rw-r--r--spec/grammar/UGI_GRAMMAR_v0.abnf148
-rw-r--r--spec/grammar/UGI_GRAMMAR_v0.ebnf167
-rw-r--r--spec/scripts/gen_abnf.py265
-rw-r--r--spec/scripts/gen_ebnf.py215
-rw-r--r--spec/scripts/gen_spec.py1053
-rw-r--r--spec/tests/cases.json780
-rw-r--r--spec/ugi_registry_v0.json134
-rw-r--r--web/LICENSE21
-rw-r--r--web/README.md46
-rw-r--r--web/scripts/gen_tool.py1388
19 files changed, 6153 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..63b0f8a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,22 @@
+# ugi
+
+Universal Geek Identifier. Spec, CLI, and web tool.
+
+## Contents
+
+- `spec/`: UGI specification, registry JSON, grammar files, generation scripts
+- `cli/`: `ugi` shell script for encoding and decoding from the command line
+- `web/`: web tool generated from the registry; run `gen_tool.py` to rebuild
+- `fonts/`: Kawkab Mono typeface used by the web tool
+
+## Build web tool
+
+```sh
+python3 web/scripts/gen_tool.py spec/ugi_registry_v0.json web/index.html
+```
+
+## Links
+
+- Live tool: <https://ugi.gumx.cc>
+- Spec: `spec/README.md`
+- CLI: `cli/README.md`
diff --git a/cli/LICENSE b/cli/LICENSE
new file mode 100644
index 0000000..dd35f66
--- /dev/null
+++ b/cli/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Ahmed Mohamed Alaa
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/cli/README.md b/cli/README.md
new file mode 100644
index 0000000..478c178
--- /dev/null
+++ b/cli/README.md
@@ -0,0 +1,118 @@
+# ugi-cli
+
+Command-line tool for encoding, decoding, and converting UGI (Unified Geek Identifier) strings.
+
+## Dependencies
+
+- **bash** 4.0+
+- **jq** — [jqlang.github.io/jq](https://jqlang.github.io/jq/)
+- **ugi_registry_v0.json** — from [ugi-spec](https://git.sr.ht/~gumxcc/ugi-spec)
+
+## Setup
+
+```bash
+git clone https://git.sr.ht/~gumxcc/ugi-cli
+cd ugi-cli
+
+# Point to the registry (pick one):
+export UGI_REGISTRY=/path/to/ugi_registry_v0.json
+# or place/symlink it next to the script:
+ln -s /path/to/ugi-spec/ugi_registry_v0.json .
+```
+
+Optionally add to `$PATH`:
+
+```bash
+ln -s "$PWD/ugi" ~/.local/bin/ugi
+```
+
+## Usage
+
+### Decode
+
+Parse a UGI string into a human-readable table:
+
+```
+$ ugi decode "ugi:0@jdoe:gcs$/dev,a4,b5/4,ppy6$js6ts5,i5"
+
+Version: 0
+Handle: @jdoe
+
+Field Code Decoded
+----------------------------------------------------------------------
+Geek Specialization g Computer Science ($), dev
+Age a4 25-34
+Build b5/4 height: Above average; width: Average
+Programming Languages p Python=Advanced / daily [$], JavaScript=Advanced / daily, TypeScript=Competent
+AI Stance i5 Positive
+```
+
+Reads from stdin if no argument given:
+
+```bash
+echo "ugi:0@jdoe:gcs,a4" | ugi decode
+```
+
+### Convert
+
+Convert between URI and block format:
+
+```bash
+# URI → Block
+ugi convert "ugi:0@jdoe:gcs/dev,a4,ppy6rs5"
+
+# Block → URI
+ugi convert <<'EOF'
+------- BEGIN UGI BLOCK -------
+v:0 @jdoe Gcs/dev
+A4
+Ppy6 Prs5
+-------- END UGI BLOCK --------
+EOF
+```
+
+### Encode
+
+Interactive guided encoder:
+
+```bash
+ugi encode
+```
+
+### Fields
+
+List all field codes:
+
+```bash
+ugi fields
+```
+
+### Short aliases
+
+```bash
+ugi d "ugi:0@jdoe:gcs,a4" # decode
+ugi c "ugi:0@jdoe:gcs,a4" # convert
+ugi e # encode
+ugi f # fields
+```
+
+## Testing
+
+Run the test suite against the shared test cases from [ugi-spec](https://git.sr.ht/~gumxcc/ugi-spec):
+
+```bash
+./test.sh /path/to/ugi_registry_v0.json /path/to/ugi-spec/tests/cases.json
+```
+
+The test runner validates decode output (version, handle, field labels) and convert roundtrip (URI → Block → URI) for each case.
+
+## Environment
+
+| Variable | Description |
+|---|---|
+| `UGI_REGISTRY` | Path to `ugi_registry_v0.json`. Falls back to `./ugi_registry_v0.json` or `../ugi-spec/ugi_registry_v0.json`. |
+
+
+## License
+
+MIT — see [LICENSE](./LICENSE).
diff --git a/cli/test.sh b/cli/test.sh
new file mode 100755
index 0000000..357beeb
--- /dev/null
+++ b/cli/test.sh
@@ -0,0 +1,200 @@
+#!/usr/bin/env bash
+# Test runner for ugi-cli against cases.json
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+UGI="$SCRIPT_DIR/ugi"
+
+if [[ $# -ne 2 ]]; then
+ echo "Usage: $0 <ugi_registry_v0.json> <cases.json>" >&2
+ exit 1
+fi
+
+REGISTRY="$1"
+CASES="$2"
+
+if [[ ! -f "$REGISTRY" ]]; then echo "error: registry not found: $REGISTRY" >&2; exit 1; fi
+if [[ ! -f "$CASES" ]]; then echo "error: cases not found: $CASES" >&2; exit 1; fi
+if [[ ! -x "$UGI" ]]; then echo "error: ugi not found at $UGI" >&2; exit 1; fi
+
+export UGI_REGISTRY="$REGISTRY"
+
+pass=0 fail=0 skip=0
+failures=()
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+expect_field_label() {
+ local case_id="$1" field_code="$2"
+ jq -r --arg id "$case_id" --arg fc "$field_code" '
+ .cases[] | select(.id == $id) | .decoded.fields[$fc] //empty |
+ if .type == "direct" then .label
+ elif .type == "special" and .dimensions then
+ [.dimensions | to_entries[] | "\(.key): \(.value.label)"] | join("; ")
+ elif .type == "special" and .domains then
+ [.domains[] | .name + (if .paid then " ($)" else "" end)] | join(", ")
+ elif .entries then
+ [.entries[] |
+ .name + (if .value != null then "=\(.label)" else "" end) +
+ (if .paid then " [$]" else "" end) +
+ (if .aspire then " [+\(.aspire)]" else "" end)
+ ] | join(", ")
+ else empty end
+ ' "$CASES"
+}
+
+run_decode_test() {
+ local case_id="$1" uri="$2"
+ local output
+ output=$("$UGI" decode "$uri" 2>&1) || true
+
+ local exp_ver
+ exp_ver=$(jq -r --arg id "$case_id" '.cases[] | select(.id == $id) | .decoded.version' "$CASES")
+ if ! echo "$output" | grep -qF "Version: $exp_ver"; then
+ failures+=("$case_id/decode/version: expected '$exp_ver'")
+ return 1
+ fi
+
+ local exp_handle
+ exp_handle=$(jq -r --arg id "$case_id" '.cases[] | select(.id == $id) | .decoded.handle' "$CASES")
+ if ! echo "$output" | grep -qF "Handle: @$exp_handle"; then
+ failures+=("$case_id/decode/handle: expected '@$exp_handle'")
+ return 1
+ fi
+
+ local field_codes
+ field_codes=$(jq -r --arg id "$case_id" '.cases[] | select(.id == $id) | .decoded.fields | keys[]' "$CASES" 2>/dev/null) || return 0
+
+ for fc in $field_codes; do
+ local exp_label
+ exp_label=$(expect_field_label "$case_id" "$fc")
+ [[ -z "$exp_label" ]] && continue
+
+ local entry_count
+ entry_count=$(jq -r --arg id "$case_id" --arg fc "$fc" '
+ .cases[] | select(.id == $id) | .decoded.fields[$fc].entries // [] | length
+ ' "$CASES")
+
+ if [[ "$entry_count" -gt 0 ]]; then
+ local entry_names
+ entry_names=$(jq -r --arg id "$case_id" --arg fc "$fc" '
+ .cases[] | select(.id == $id) | .decoded.fields[$fc].entries[]? | .name
+ ' "$CASES")
+ for ename in $entry_names; do
+ if ! echo "$output" | grep -qF "$ename"; then
+ failures+=("$case_id/decode/$fc: missing entry name '$ename' in output")
+ return 1
+ fi
+ done
+ elif [[ -n "$exp_label" ]]; then
+ IFS=';' read -ra label_parts <<< "$exp_label"
+ for lp in "${label_parts[@]}"; do
+ lp="${lp## }"
+ if ! echo "$output" | grep -qF "$lp"; then
+ failures+=("$case_id/decode/$fc: missing '$lp' in output")
+ return 1
+ fi
+ done
+ fi
+ done
+
+ return 0
+}
+
+run_convert_test() {
+ local case_id="$1" uri="$2" expected_block="$3"
+
+ local block_output
+ block_output=$("$UGI" convert "$uri" 2>&1) || true
+
+ if ! echo "$block_output" | grep -qF "BEGIN UGI BLOCK"; then
+ failures+=("$case_id/convert-uri2block: missing BEGIN header")
+ return 1
+ fi
+ if ! echo "$block_output" | grep -qF "END UGI BLOCK"; then
+ failures+=("$case_id/convert-uri2block: missing END footer")
+ return 1
+ fi
+
+ local exp_handle
+ exp_handle=$(jq -r --arg id "$case_id" '.cases[] | select(.id == $id) | .decoded.handle' "$CASES")
+ if ! echo "$block_output" | grep -qF "@$exp_handle"; then
+ failures+=("$case_id/convert-uri2block: missing handle '@$exp_handle'")
+ return 1
+ fi
+
+ local uri_output
+ uri_output=$("$UGI" convert "$block_output" 2>&1) || true
+
+ if [[ "${uri_output,,}" != "ugi:"* ]]; then
+ failures+=("$case_id/convert-block2uri: output doesn't start with 'ugi:' got '$uri_output'")
+ return 1
+ fi
+
+ if ! echo "$uri_output" | grep -qF "@$exp_handle"; then
+ failures+=("$case_id/convert-block2uri: missing handle in roundtrip")
+ return 1
+ fi
+
+ return 0
+}
+
+# ── Run tests ────────────────────────────────────────────────────────────────
+
+echo "=== UGI CLI Test Suite ==="
+echo "Registry: $REGISTRY"
+echo "Cases: $CASES"
+echo "CLI: $UGI"
+echo
+
+case_ids=$(jq -r '.cases[].id' "$CASES")
+
+for cid in $case_ids; do
+ uri=$(jq -r --arg id "$cid" '.cases[] | select(.id == $id) | .uri // empty' "$CASES")
+ has_decoded=$(jq -r --arg id "$cid" '.cases[] | select(.id == $id) | .decoded // empty | if . then "yes" else "no" end' "$CASES")
+
+ if [[ -z "$uri" ]]; then
+ printf " SKIP %-35s (no single URI)\n" "$cid"
+ skip=$((skip + 1))
+ continue
+ fi
+
+ if [[ "$has_decoded" == "no" || -z "$has_decoded" ]]; then
+ printf " SKIP %-35s (no decoded expectations)\n" "$cid"
+ skip=$((skip + 1))
+ continue
+ fi
+
+ if run_decode_test "$cid" "$uri"; then
+ printf " PASS %-35s decode\n" "$cid"
+ pass=$((pass + 1))
+ else
+ printf " FAIL %-35s decode\n" "$cid"
+ fail=$((fail + 1))
+ fi
+
+ block=$(jq -r --arg id "$cid" '.cases[] | select(.id == $id) | .block // empty' "$CASES")
+ if [[ -n "$block" ]]; then
+ if run_convert_test "$cid" "$uri" "$block"; then
+ printf " PASS %-35s convert roundtrip\n" "$cid"
+ pass=$((pass + 1))
+ else
+ printf " FAIL %-35s convert roundtrip\n" "$cid"
+ fail=$((fail + 1))
+ fi
+ fi
+done
+
+echo
+echo "=== Results: $pass passed, $fail failed, $skip skipped ==="
+
+if [[ ${#failures[@]} -gt 0 ]]; then
+ echo
+ echo "Failures:"
+ for f in "${failures[@]}"; do
+ echo " - $f"
+ done
+ exit 1
+fi
+
+exit 0
diff --git a/cli/ugi b/cli/ugi
new file mode 100755
index 0000000..16fca01
--- /dev/null
+++ b/cli/ugi
@@ -0,0 +1,660 @@
+#!/usr/bin/env bash
+# ugi — encode, decode, and convert UGI strings
+set -euo pipefail
+
+VERSION="0.1.0"
+
+# ── Registry path ────────────────────────────────────────────────────────────
+
+REGISTRY="${UGI_REGISTRY:-}"
+if [[ -z "$REGISTRY" ]]; then
+ # Try common locations relative to script
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ for candidate in \
+ "$SCRIPT_DIR/ugi_registry_v0.json" \
+ "$SCRIPT_DIR/../ugi-spec/ugi_registry_v0.json" \
+ "$SCRIPT_DIR/ugi-spec/ugi_registry_v0.json"; do
+ if [[ -f "$candidate" ]]; then
+ REGISTRY="$candidate"
+ break
+ fi
+ done
+fi
+
+require_registry() {
+ if [[ -z "$REGISTRY" || ! -f "$REGISTRY" ]]; then
+ echo "error: registry not found. set UGI_REGISTRY or place ugi_registry_v0.json nearby." >&2
+ exit 1
+ fi
+}
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+jq_check() {
+ if ! command -v jq &>/dev/null; then
+ echo "error: jq is required. install it: https://jqlang.github.io/jq/" >&2
+ exit 1
+ fi
+}
+
+# ── Decode ───────────────────────────────────────────────────────────────────
+
+decode_uri() {
+ local uri="$1"
+ require_registry
+ jq_check
+
+ # Strip ugi: prefix, split header and fields
+ local rest="${uri#ugi:}"
+ local version="${rest%%@*}"
+ rest="${rest#*@}"
+ local handle="${rest%%:*}"
+ local fields_str="${rest#*:}"
+
+ printf "Version: %s\nHandle: @%s\n\n" "$version" "$handle"
+ printf "%-25s %-8s %s\n" "Field" "Code" "Decoded"
+ printf "%s\n" "----------------------------------------------------------------------"
+
+ IFS=',' read -ra field_parts <<< "$fields_str"
+ for part in "${field_parts[@]}"; do
+ [[ -z "$part" ]] && continue
+ local code="${part:0:1}"
+ local rest_field="${part:1}"
+ decode_single_field "$code" "$rest_field" "$part"
+ done
+}
+
+decode_single_field() {
+ local code="$1" rest="$2" raw="$3"
+ local name type
+
+ name=$(jq -r --arg c "$code" '.fields[$c].name // "Unknown"' "$REGISTRY")
+ type=$(jq -r --arg c "$code" '.fields[$c].type // "unknown"' "$REGISTRY")
+
+ case "$type" in
+ direct)
+ decode_direct "$code" "$rest" "$name"
+ ;;
+ special)
+ decode_special "$code" "$rest" "$name"
+ ;;
+ multi)
+ decode_multi "$code" "$rest" "$name"
+ ;;
+ single)
+ decode_multi "$code" "$rest" "$name"
+ ;;
+ *)
+ printf "%-25s %-8s %s\n" "$name" "$code" "$rest"
+ ;;
+ esac
+}
+
+decode_direct() {
+ local code="$1" rest="$2" name="$3"
+ [[ -z "$rest" ]] && return
+
+ local digit="${rest:0:1}"
+ local mods="${rest:1}"
+ local label
+
+ # Try custom values first, then scale_labels, then global scale
+ label=$(jq -r --arg c "$code" --arg d "$digit" '
+ .fields[$c].values[$d].label //
+ .fields[$c].scale_labels[$d] //
+ empty
+ ' "$REGISTRY" 2>/dev/null || true)
+
+ if [[ -z "$label" ]]; then
+ local scale_type
+ scale_type=$(jq -r --arg c "$code" '.fields[$c].scale_type // ""' "$REGISTRY")
+ if [[ -n "$scale_type" && "$scale_type" != "custom" ]]; then
+ label=$(jq -r --arg d "$digit" --arg st "$scale_type" '
+ .scale.values[] | select(.value == ($d | tonumber)) | .[$st] // empty
+ ' "$REGISTRY" 2>/dev/null || true)
+ fi
+ fi
+ [[ -z "$label" ]] && label="$digit"
+
+ # Parse modifiers
+ local mod_str=""
+ if [[ "$mods" == *"/"* ]]; then
+ local alt_digit="${mods#*/}"
+ alt_digit="${alt_digit:0:1}"
+ local alt_label
+ alt_label=$(jq -r --arg c "$code" --arg d "$alt_digit" '
+ .fields[$c].values[$d].label // $d
+ ' "$REGISTRY" 2>/dev/null || true)
+ label="$label / $alt_label"
+ mods="${mods//\/$alt_digit/}"
+ fi
+ [[ "$mods" == *'$'* ]] && mod_str+=" [\$]"
+ if [[ "$mods" == *"+"* ]]; then
+ local asp="${mods#*+}"
+ asp="${asp:0:1}"
+ mod_str+=" [+$asp]"
+ fi
+
+ printf "%-25s %-8s %s%s\n" "$name" "${code}${digit}" "$label" "$mod_str"
+}
+
+decode_special() {
+ local code="$1" rest="$2" name="$3"
+
+ case "$code" in
+ g)
+ # Geek type: domain/domain/$
+ local decoded=""
+ IFS='/' read -ra domains <<< "$rest"
+ for d in "${domains[@]}"; do
+ local paid=""
+ local dname="$d"
+ [[ "$d" == *'$' ]] && { paid=" (\$)"; dname="${d%\$}"; }
+
+ local resolved
+ if [[ "$dname" == "~"* ]]; then
+ resolved="$dname"
+ else
+ resolved=$(jq -r --arg sid "$dname" '
+ [.fields.g.sub_id_groups[].sub_ids[$sid] // empty] | first // $sid
+ ' "$REGISTRY" 2>/dev/null || echo "$dname")
+ # Handle string vs object
+ if [[ "$resolved" == "{"* ]]; then
+ resolved=$(echo "$resolved" | jq -r '.name // empty' 2>/dev/null || echo "$dname")
+ fi
+ fi
+ [[ -n "$decoded" ]] && decoded+=", "
+ decoded+="${resolved}${paid}"
+ done
+ printf "%-25s %-8s %s\n" "$name" "$code" "$decoded"
+ ;;
+ b|v)
+ IFS='/' read -ra dims <<< "$rest"
+ local dim_names dim_labels=""
+ dim_names=$(jq -r --arg c "$code" '.fields[$c].dimensions | keys_unsorted[]' "$REGISTRY")
+ local i=0
+ while IFS= read -r dim_name; do
+ local val="${dims[$i]:-}"
+ if [[ -n "$val" ]]; then
+ local dim_label
+ dim_label=$(jq -r --arg c "$code" --arg dn "$dim_name" --arg v "$val" '
+ .fields[$c].dimensions[$dn].values[$v] // $v
+ ' "$REGISTRY")
+ [[ -n "$dim_labels" ]] && dim_labels+="; "
+ dim_labels+="${dim_name}: ${dim_label}"
+ fi
+ i=$((i + 1)) || true
+ done <<< "$dim_names"
+ printf "%-25s %-8s %s\n" "$name" "${code}${rest}" "$dim_labels"
+ ;;
+ l)
+ # Languages: 2-char code + digit + mods, repeating
+ decode_multi_inner "$code" "$rest" "$name" 2
+ ;;
+ esac
+}
+
+decode_multi() {
+ local code="$1" rest="$2" name="$3"
+ decode_multi_inner "$code" "$rest" "$name" 0
+}
+
+decode_multi_inner() {
+ local code="$1" rest="$2" name="$3" fixed_len="$4"
+ local entries=""
+ local i=0 len=${#rest}
+
+ while (( i < len )); do
+ local ch="${rest:$i:1}"
+ local sub_id=""
+
+ if [[ "$ch" == "~" ]]; then
+ # Custom sub-ID
+ local j=$((i + 1))
+ while (( j < len )) && [[ "${rest:$j:1}" =~ [a-zA-Z] ]]; do
+ j=$((j + 1))
+ done
+ sub_id="${rest:$i:$((j - i))}"
+ i=$j
+ elif [[ "$ch" =~ [a-zA-Z] ]]; then
+ if (( fixed_len > 0 )); then
+ sub_id="${rest:$i:$fixed_len}"
+ i=$((i + fixed_len))
+ else
+ local j=$((i))
+ while (( j < len )) && [[ "${rest:$j:1}" =~ [a-zA-Z] ]]; do
+ j=$((j + 1))
+ done
+ sub_id="${rest:$i:$((j - i))}"
+ i=$j
+ fi
+ else
+ i=$((i + 1))
+ continue
+ fi
+
+ # Resolve name
+ local resolved
+ if [[ "$sub_id" == "~"* ]]; then
+ resolved="$sub_id"
+ else
+ resolved=$(jq -r --arg c "$code" --arg sid "$sub_id" '
+ ((.fields[$c].sub_ids[$sid] // null) as $flat |
+ if $flat then
+ if ($flat | type) == "object" then $flat.name else $flat end
+ else null end) //
+ (if .fields[$c].sub_id_groups then
+ [.fields[$c].sub_id_groups[].sub_ids[$sid] // empty] | first |
+ if type == "object" then .name else . end
+ else null end) //
+ (.fields[$c].common_codes[$sid] // null) //
+ $sid
+ ' "$REGISTRY" 2>/dev/null || echo "$sub_id")
+ fi
+
+ # Read digit
+ local digit=""
+ if (( i < len )) && [[ "${rest:$i:1}" =~ [0-7] ]]; then
+ digit="${rest:$i:1}"
+ i=$((i + 1))
+ fi
+
+ # Read modifiers
+ local mod_str=""
+ if (( i < len )) && [[ "${rest:$i:1}" == '$' ]]; then
+ mod_str+=" [\$]"
+ i=$((i + 1))
+ fi
+ if (( i < len )) && [[ "${rest:$i:1}" == "+" ]]; then
+ i=$((i + 1))
+ if (( i < len )) && [[ "${rest:$i:1}" =~ [0-7] ]]; then
+ mod_str+=" [+${rest:$i:1}]"
+ i=$((i + 1))
+ fi
+ fi
+
+ # Get label for digit
+ local label=""
+ if [[ -n "$digit" ]]; then
+ label=$(jq -r --arg c "$code" --arg d "$digit" '
+ .fields[$c].values[$d].label //
+ .fields[$c].scale_labels[$d] //
+ empty
+ ' "$REGISTRY" 2>/dev/null || true)
+ if [[ -z "$label" ]]; then
+ local st
+ st=$(jq -r --arg c "$code" '.fields[$c].scale_type // ""' "$REGISTRY")
+ if [[ -n "$st" && "$st" != "custom" ]]; then
+ label=$(jq -r --arg d "$digit" --arg st "$st" '
+ .scale.values[] | select(.value == ($d | tonumber)) | .[$st] // empty
+ ' "$REGISTRY" 2>/dev/null || true)
+ fi
+ fi
+ [[ -z "$label" ]] && label="$digit"
+ fi
+
+ [[ -n "$entries" ]] && entries+=", "
+ if [[ -n "$digit" ]]; then
+ entries+="${resolved}=${label}${mod_str}"
+ else
+ entries+="${resolved}${mod_str}"
+ fi
+ done
+
+ printf "%-25s %-8s %s\n" "$name" "$code" "$entries"
+}
+
+# ── Convert ──────────────────────────────────────────────────────────────────
+
+convert_uri_to_block() {
+ local uri="$1"
+ require_registry
+ jq_check
+
+ local rest="${uri#ugi:}"
+ local version="${rest%%@*}"
+ rest="${rest#*@}"
+ local handle="${rest%%:*}"
+ local fields_str="${rest#*:}"
+
+ echo "------- BEGIN UGI BLOCK -------"
+
+ local first_line="v:${version} @${handle}"
+ local -a remaining=()
+
+ IFS=',' read -ra parts <<< "$fields_str"
+ for part in "${parts[@]}"; do
+ [[ -z "$part" ]] && continue
+ local code="${part:0:1}"
+ if [[ "$code" == "g" ]]; then
+ first_line+=" G${part:1}"
+ else
+ remaining+=("$part")
+ fi
+ done
+ echo "$first_line"
+
+ # Group by category
+ local -A cat_fields
+ local -a cat_order=(identity appearance tech stance entertainment lifestyle)
+ for cat in "${cat_order[@]}"; do
+ cat_fields[$cat]=""
+ done
+
+ for part in "${remaining[@]}"; do
+ local code="${part:0:1}"
+ local cat
+ cat=$(jq -r --arg c "$code" '.fields[$c].category // "tech"' "$REGISTRY")
+ local ftype
+ ftype=$(jq -r --arg c "$code" '.fields[$c].type // "direct"' "$REGISTRY")
+ local uc="${code^^}"
+
+ if [[ "$ftype" == "multi" || "$ftype" == "single" ]]; then
+ # Expand merged entries
+ local expanded
+ expanded=$(expand_multi_block "$uc" "${part:1}" "$code")
+ cat_fields[$cat]+="${cat_fields[$cat]:+ }${expanded}"
+ else
+ cat_fields[$cat]+="${cat_fields[$cat]:+ }${uc}${part:1}"
+ fi
+ done
+
+ for cat in "${cat_order[@]}"; do
+ [[ -n "${cat_fields[$cat]:-}" ]] && echo "${cat_fields[$cat]}"
+ done
+
+ echo "-------- END UGI BLOCK --------"
+}
+
+expand_multi_block() {
+ local uc="$1" rest="$2" code="$3"
+ local result="" i=0 len=${#rest}
+ local fixed_len=0
+ [[ "$code" == "l" ]] && fixed_len=2
+
+ while (( i < len )); do
+ local ch="${rest:$i:1}"
+ local sub_id="" tail=""
+
+ if [[ "$ch" == "~" ]]; then
+ local j=$((i + 1))
+ while (( j < len )) && [[ "${rest:$j:1}" =~ [a-zA-Z] ]]; do j=$((j + 1)); done
+ sub_id="${rest:$i:$((j - i))}"
+ i=$j
+ elif [[ "$ch" =~ [a-zA-Z] ]]; then
+ if (( fixed_len > 0 )); then
+ sub_id="${rest:$i:$fixed_len}"
+ i=$((i + fixed_len))
+ else
+ local j=$i
+ while (( j < len )) && [[ "${rest:$j:1}" =~ [a-zA-Z] ]]; do j=$((j + 1)); done
+ sub_id="${rest:$i:$((j - i))}"
+ i=$j
+ fi
+ else
+ i=$((i + 1)); continue
+ fi
+
+ # digit + modifiers
+ while (( i < len )) && [[ "${rest:$i:1}" =~ [0-9\$+] ]]; do
+ tail+="${rest:$i:1}"
+ i=$((i + 1))
+ done
+
+ result+="${result:+ }${uc}${sub_id}${tail}"
+ done
+
+ echo "$result"
+}
+
+convert_block_to_uri() {
+ local block="$1"
+ require_registry
+ jq_check
+
+ local version="" handle=""
+ local -a field_parts=()
+
+ while IFS= read -r line; do
+ line="${line#"${line%%[![:space:]]*}"}" # trim leading whitespace
+ [[ "$line" == "---"*"BEGIN"* ]] && continue
+ [[ "$line" == "---"*"END"* ]] && continue
+ [[ -z "$line" ]] && continue
+
+ for token in $line; do
+ if [[ "$token" == "v:"* ]]; then
+ version="${token#v:}"
+ elif [[ "$token" == "@"* ]]; then
+ handle="${token#@}"
+ elif [[ "${token:0:1}" =~ [A-Za-z] ]]; then
+ field_parts+=("$token")
+ fi
+ done
+ done <<< "$block"
+
+ # Merge repeated field codes
+ local -A merged
+ local -a order=()
+ for part in "${field_parts[@]}"; do
+ local code="${part:0:1}"
+ code="${code,,}" # lowercase
+ if [[ -z "${merged[$code]+x}" ]]; then
+ merged[$code]="${part:1}"
+ order+=("$code")
+ else
+ merged[$code]+="${part:1}"
+ fi
+ done
+
+ local fields_str=""
+ for code in "${order[@]}"; do
+ [[ -n "$fields_str" ]] && fields_str+=","
+ fields_str+="${code}${merged[$code]}"
+ done
+
+ echo "ugi:${version}@${handle}:${fields_str}"
+}
+
+# ── Fields ───────────────────────────────────────────────────────────────────
+
+list_fields() {
+ require_registry
+ jq_check
+
+ printf "%-6s %-25s %-15s %s\n" "Code" "Field" "Type" "Category"
+ printf "%s\n" "------------------------------------------------------------"
+
+ jq -r '
+ .fields | to_entries | sort_by(.key)[] |
+ "\(.key)\t\(.value.name)\t\(.value.type)\t\(.value.category)"
+ ' "$REGISTRY" | while IFS=$'\t' read -r code fname ftype fcat; do
+ printf "%-6s %-25s %-15s %s\n" "$code" "$fname" "$ftype" "$fcat"
+ done
+
+ # Reserved codes
+ jq -r '.format.reserved_codes[]' "$REGISTRY" 2>/dev/null | sort | while read -r code; do
+ printf "%-6s %-25s %-15s %s\n" "$code" "(reserved)" "—" "—"
+ done
+}
+
+# ── Encode (interactive) ────────────────────────────────────────────────────
+
+encode_interactive() {
+ require_registry
+ jq_check
+
+ echo "=== UGI Encoder ==="
+ echo
+
+ # Handle
+ read -rp "Handle (username): " handle
+ [[ -z "$handle" ]] && { echo "Handle is required." >&2; exit 1; }
+
+ local -a parts=()
+
+ # Geek type (mandatory)
+ echo
+ echo "--- Geek Specialization (g) --- [mandatory]"
+ echo "Enter domain codes separated by /. Use ~ for custom, \$ after domain for paid."
+ echo "Example: cs\$/ai/wr~fermenting"
+ echo
+ echo "Available domains:"
+ jq -r '
+ .fields.g.sub_id_groups | to_entries[] |
+ " \(.value.label): " + ([.value.sub_ids | to_entries[:6][] | "\(.key)=\(if (.value|type) == "object" then .value.name else .value end)"] | join(", ")) + "..."
+ ' "$REGISTRY"
+ echo
+ read -rp "Geek domains: " geek
+ [[ -z "$geek" ]] && { echo "Geek type is required." >&2; exit 1; }
+ parts+=("g${geek}")
+
+ # Other fields
+ local field_codes
+ field_codes=$(jq -r '.fields | keys[] | select(. != "g")' "$REGISTRY" | sort)
+
+ for code in $field_codes; do
+ local name type
+ name=$(jq -r --arg c "$code" '.fields[$c].name' "$REGISTRY")
+ type=$(jq -r --arg c "$code" '.fields[$c].type' "$REGISTRY")
+
+ echo
+ echo "--- ${name} (${code}) --- [s=skip]"
+
+ case "$type" in
+ direct)
+ # Show scale
+ jq -r --arg c "$code" '
+ .fields[$c].values // {} | to_entries | sort_by(.key)[] |
+ " \(.key): \(.value.label // .value)"
+ ' "$REGISTRY" 2>/dev/null || true
+
+ read -rp "Value (0-7, s=skip): " val
+ [[ "$val" == "s" || -z "$val" ]] && continue
+ [[ "$val" =~ ^[0-7]$ ]] || { echo "Invalid, skipping."; continue; }
+ parts+=("${code}${val}")
+ ;;
+ special)
+ case "$code" in
+ b)
+ echo " Height and width, each 0-7"
+ read -rp " Height (0-7, s=skip): " h
+ [[ "$h" == "s" || -z "$h" ]] && continue
+ read -rp " Width (0-7): " w
+ [[ -z "$w" ]] && continue
+ parts+=("b${h}/${w}")
+ ;;
+ v)
+ echo " Social: 0=far-right auth → 7=far-left lib"
+ echo " Economic: 0=unreg capitalism → 7=full socialism"
+ read -rp " Social (0-7, s=skip): " s
+ [[ "$s" == "s" || -z "$s" ]] && continue
+ read -rp " Economic (0-7): " e
+ [[ -z "$e" ]] && continue
+ parts+=("v${s}/${e}")
+ ;;
+ l)
+ echo " Enter ISO 639-1 codes with ratings, no spaces."
+ echo " Example: en7ar6de3"
+ read -rp " Languages (s=skip): " langs
+ [[ "$langs" == "s" || -z "$langs" ]] && continue
+ parts+=("l${langs}")
+ ;;
+ esac
+ ;;
+ multi|single)
+ jq -r --arg c "$code" '
+ ((.fields[$c].sub_ids // {}) | to_entries[:8][] |
+ "\(.key)=\(if (.value|type) == "object" then .value.name else .value end)"),
+ ((.fields[$c].sub_id_groups // {}) | to_entries[] | .value.sub_ids | to_entries[:4][] |
+ "\(.key)=\(if (.value|type) == "object" then .value.name else .value end)")
+ ' "$REGISTRY" 2>/dev/null | head -12 | sed 's/^/ /'
+ echo " ..."
+
+ if [[ "$type" == "single" ]]; then
+ echo " Enter one sub-ID + rating. Example: is6"
+ else
+ echo " Enter sub-ID+rating pairs. Example: py6rs5js4"
+ fi
+ read -rp " ${name} (s=skip): " val
+ [[ "$val" == "s" || -z "$val" ]] && continue
+ parts+=("${code}${val}")
+ ;;
+ esac
+ done
+
+ local uri
+ uri="ugi:0@${handle}:$(IFS=,; echo "${parts[*]}")"
+
+ echo
+ echo "=== Result ==="
+ echo
+ echo "$uri"
+ echo
+ convert_uri_to_block "$uri"
+}
+
+# ── Main ─────────────────────────────────────────────────────────────────────
+
+usage() {
+ cat <<'EOF'
+ugi — UGI (Unified Geek Identifier) CLI tool
+
+Usage:
+ ugi decode <ugi-string> Decode a UGI string (URI or block)
+ ugi convert <ugi-string> Convert between URI and block format
+ ugi encode Interactive encoder
+ ugi fields List all field codes
+
+ Aliases: d=decode, c=convert, e=encode, f=fields
+
+Environment:
+ UGI_REGISTRY Path to ugi_registry_v0.json
+
+Options:
+ -h, --help Show this help
+ -v, --version Show version
+
+Reads from stdin if no string argument given (decode/convert).
+EOF
+}
+
+main() {
+ [[ $# -eq 0 ]] && { usage; exit 0; }
+
+ case "$1" in
+ -h|--help) usage; exit 0 ;;
+ -v|--version) echo "ugi $VERSION"; exit 0 ;;
+ decode|d)
+ shift
+ local input="${1:-}"
+ [[ -z "$input" ]] && input="$(cat)"
+ if [[ "${input,,}" == "ugi:"* ]]; then
+ decode_uri "$input"
+ elif [[ "$input" == *"BEGIN UGI"* || "$input" == *"v:"* ]]; then
+ # Parse block to URI first, then decode
+ local uri
+ uri=$(convert_block_to_uri "$input")
+ decode_uri "$uri"
+ else
+ echo "error: unrecognized format" >&2; exit 1
+ fi
+ ;;
+ convert|c)
+ shift
+ local input="${1:-}"
+ [[ -z "$input" ]] && input="$(cat)"
+ if [[ "${input,,}" == "ugi:"* ]]; then
+ convert_uri_to_block "$input"
+ elif [[ "$input" == *"BEGIN UGI"* || "$input" == *"v:"* ]]; then
+ convert_block_to_uri "$input"
+ else
+ echo "error: unrecognized format" >&2; exit 1
+ fi
+ ;;
+ encode|e) encode_interactive ;;
+ fields|f) list_fields ;;
+ *) echo "error: unknown command '$1'" >&2; usage; exit 1 ;;
+ esac
+}
+
+main "$@"
diff --git a/fonts/KawkabMono-Bold.woff2 b/fonts/KawkabMono-Bold.woff2
new file mode 100644
index 0000000..f8f61f7
--- /dev/null
+++ b/fonts/KawkabMono-Bold.woff2
Binary files differ
diff --git a/fonts/KawkabMono-Regular.woff2 b/fonts/KawkabMono-Regular.woff2
new file mode 100644
index 0000000..92d4d15
--- /dev/null
+++ b/fonts/KawkabMono-Regular.woff2
Binary files differ
diff --git a/spec/LICENSE b/spec/LICENSE
new file mode 100644
index 0000000..dd35f66
--- /dev/null
+++ b/spec/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Ahmed Mohamed Alaa
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/spec/README.md b/spec/README.md
new file mode 100644
index 0000000..3cedcbb
--- /dev/null
+++ b/spec/README.md
@@ -0,0 +1,894 @@
+# UGI — Unified Geek Identifier
+
+**Version: 0 (Draft) · 2026-03-28 · MIT License**
+
+---
+## Overview
+
+A compact, URI-safe self-description code for geeks. Encodes identity, skills, interests, and opinions into a single string suitable for bios, email signatures, and URIs.
+
+It descends from two predecessors:
+
+- [Geek Code](https://sig.codes/geek/) by Robert A. Hayden (1993-1996), with its [Geek Code 4.0+](https://sig.codes/geek-4.0/) by exarobibliologist (2019-2026)
+- [Hacker Key](https://sig.codes/hacker-key/) by Chris Allegretta (2003-2006)
+
+UGI takes the cultural breadth of the Geek Code, the numeric compactness of the Hacker Key, and adds URI safety, modern fields, and extensibility.
+
+### Design goals
+
+1. Single-letter field codes — 24 active, 2 reserved
+2. Octal (0–7) rating scale
+3. URI-safe characters only — no percent-encoding needed
+4. Dual format — URI for machines, block for humans
+5. Case-insensitive
+6. Extensible via `~` custom sub-IDs
+
+---
+## Formats
+
+### URI
+
+```
+ugi:<version>[/<revision>]@<handle>:<field>,<field>,...
+```
+
+### Block
+
+```
+------- BEGIN UGI BLOCK -------
+v:<version>[/<revision>] @<handle> [G<geek_types>]
+<field> <field> ...
+-------- END UGI BLOCK --------
+```
+
+In block format, field codes are repeated per sub-ID for readability: `Ppy6 Prs5` instead of `ppy6rs5`. Long-form sub-ID aliases (noted in registries) are permitted in block format.
+
+### Conversion
+
+Block → URI: remove header/footer, merge repeated field codes, replace spaces with commas, prepend `ugi:`.
+
+URI → Block: split on commas, expand merged sub-IDs by repeating field codes, add header/footer.
+
+No modifier or character changes are needed — both formats use identical symbols.
+
+### Mandatory fields
+
+`@handle` and `g` (geek type) are required. All others are optional.
+
+---
+## Grammar
+
+```
+<field_code><sub_id><digit>[<modifier>]...
+```
+
+- **Field codes** — single letter (a–z), case-insensitive
+- **Sub-IDs** — 1–4 lowercase alpha chars
+- **Ratings** — single octal digit (0–7), required on every sub-ID
+- **Custom sub-IDs** — `~` prefix: `~fermenting5`
+- **Modifiers** — after the digit:
+ - `$` — paid
+ - `+` followed by digit — aspiring toward that level
+ - `$+` combined — paid and aspiring
+- **Alternatives** — `/` between two digits on direct-value fields: `c3/6`
+
+### Special formats
+
+| Field | Format | Example |
+|---|---|---|
+| `g` geek type | `/` separates domains, `~` for custom, `$` for paid, no ratings | `gcs$/ai/wr~fermenting` |
+| `b` build | `<height>/<width>` | `b6/3` |
+| `l` languages | ISO 639-1 code + rating | `lar7en6` |
+| `v` politics | `<social>/<economic>` | `v5/3` |
+
+### Parser algorithm
+
+After each comma or space, read a single letter (field code). Then:
+
+- `g` → read alpha tokens separated by `/` and `~`-prefixed tokens until delimiter
+- `b` → digit, `/`, digit
+- `v` → digit, `/`, digit
+- `l` → repeat: 2 alpha + digit + optional modifier
+- `r` → one sub-id + digit (single value only)
+- direct fields → digit + optional modifier
+- direct-alt fields → digit + optional `/` + digit + optional modifier
+- multi fields → repeat: alpha (sub-id) + digit + optional modifier
+
+---
+## Rating scale (0–7)
+
+| Value | General | Proficiency | Enthusiasm | Stance |
+|---|---|---|---|---|
+| 0 | Hostile / opposed | Can't, won't | Despise | Strongly against |
+| 1 | Strong dislike | Aware it exists | Avoid actively | Against |
+| 2 | Dislike / minimal | Dabbled briefly | Not interested | Lean against |
+| 3 | Slight negative | Beginner | Cold | Slightly against |
+| 4 | Baseline / neutral | Average | Take or leave | No opinion |
+| 5 | Moderate positive | Competent | Enjoy | Lean toward |
+| 6 | Strong positive | Advanced / daily | Enthusiast | Support |
+| 7 | Obsessed / defining | Master / creator | Life-defining | Evangelist |
+### Modifier permissions
+
+| Modifiers | Fields |
+|---|---|
+| `$` and `+` | `f` `j` `l` `o` `p` `q` `w` `y` `z` |
+| `$` only | `g` |
+| `+` only | `b` `c` `d` `e` `h` `k` `s` |
+| Neither | `a` `i` `m` `r` `t` `v` `x` |
+
+---
+## Field list
+
+| Code | Field | Type | Category |
+|---|---|---|---|
+| `a` | Age | Direct | Identity |
+| `b` | Build | Special (h/w) | Appearance |
+| `c` | Clothing | Direct, `/` ok | Appearance |
+| `d` | Editor / IDE | Multi | Tech |
+| `e` | Education | Direct | Identity |
+| `f` | Food / Cooking | Direct | Lifestyle |
+| `g` | Geek Specialization | Special | Identity |
+| `h` | Hair | Multi (1-letter) | Appearance |
+| `i` | AI Stance | Direct | Stance |
+| `j` | Gaming | Multi | Entertainment |
+| `k` | Books | Direct | Entertainment |
+| `l` | Spoken Languages | Multi (ISO) | Identity |
+| `m` | Music | Multi | Entertainment |
+| `n` | *(reserved)* | — | — |
+| `o` | Operating System | Multi | Tech |
+| `p` | Programming Languages | Multi | Tech |
+| `q` | Security / Hacking | Direct, `/` ok | Tech |
+| `r` | Religion | Single | Stance |
+| `s` | Sex / Relationships | Direct, `/` ok | Lifestyle |
+| `t` | TV / Film | Multi | Entertainment |
+| `u` | *(reserved)* | — | — |
+| `v` | Politics | Special (s/e) | Stance |
+| `w` | Hardware | Direct | Tech |
+| `x` | Comics / Manga | Multi | Entertainment |
+| `y` | Fitness | Direct | Lifestyle |
+| `z` | Maker / DIY | Direct | Lifestyle |
+
+---
+## Field definitions
+
+---
+
+### a — Age
+
+0. **Under 10** — Still loading. Baby Shark has more life experience.
+1. **10-14** — Old enough for Linux opinions, too young to drive to the meetup.
+2. **15-19** — Convinced you know everything. Might be right about the tech stuff.
+3. **20-24** — Student loans arrived but so has 3am pizza freedom.
+4. **25-34** — Peak earning-to-caring ratio. GitHub green, back complaining.
+5. **35-49** — You remember dial-up noises. Kids ask what a floppy disk is.
+6. **50-64** — You were there when it happened. All of it.
+7. **65+** — Living legend. Your software runs on OSes born decades after you.
+
+---
+
+### b — Build
+
+Format: `b<height>/<width>`. Each 0–7. 0 = extremely small, 4 = average, 7 = extremely large. `+` modifier permitted.
+
+**Height:** 0 = Extremely short · 1 = Very short · 2 = Short · 3 = Below average · 4 = Average · 5 = Above average · 6 = Tall · 7 = Extremely tall
+
+**Width:** 0 = Extremely thin · 1 = Very thin · 2 = Slim · 3 = Below average · 4 = Average · 5 = Above average · 6 = Broad · 7 = Extremely broad
+
+> `b6/3` — tall and slim.
+> `b4/4` — average. Chairs were designed for you.
+
+---
+
+### c — Clothing
+
+0. **Nudist** — Quite a fashion statement.
+1. **Punk** — Safety pins are load-bearing.
+2. **Political tees** — Your shirts have opinions so you don't have to.
+3. **Jeans & tee** — Universal geek uniform. You own seven identical shirts.
+4. **Average** — Inoffensive. Forgettable. Exactly as intended.
+5. **Smart casual** — You've mastered the blazer-over-tee.
+6. **Formal** — You iron things. On purpose.
+7. **Suit daily** — CEO, lawyer, or cosplaying as one.
+
+---
+
+### d — Editor / IDE
+
+The editor holy war, now quantified.
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `vi` | `(vim)` | Vim / Neovim | `em` | `(emacs)` | Emacs |
+| `vs` | `(vscode)` | VS Code | `ij` | `(idea)` | IntelliJ / JetBrains |
+| `xc` | `(xcode)` | Xcode | `vt` | `(vstudio)` | Visual Studio |
+| `ed` | — | ed | `na` | `(nano)` | nano / pico |
+| `su` | `(sublime)` | Sublime Text | `ec` | `(eclipse)` | Eclipse |
+| `np` | `(npp)` | Notepad++ | `hx` | `(helix)` | Helix |
+| `ze` | `(zed)` | Zed | `ka` | `(kakoune)` | Kakoune |
+| `lp` | `(lapce)` | Lapce | `at` | `(atom)` | Atom |
+| `nv` | `(nvchad)` | NvChad / LazyVim | | | |
+
+Scale: **proficiency** (see global scale table).
+
+> 0: Opened it by accident, couldn't close it.
+> 7: The editor is an extension of my nervous system.
+
+---
+
+### e — Education
+
+0. **None / primary** — School of life. And YouTube.
+1. **Middle school** — Survived. Emotionally, jury's still out.
+2. **High school** — They said best years. They lied.
+3. **Trade / associates** — You can actually do things, unlike philosophy majors.
+4. **Bachelors** — Four years and paper that cost more than your car.
+5. **Masters** — First degree wasn't enough to convince anyone.
+6. **PhD** — Call me Doctor. Especially at parties.
+7. **Post-doc** — Forgotten what sunlight looks like.
+
+---
+
+### f — Food / Cooking
+
+0. **Microwave only** — Smoke detector is your kitchen timer.
+1. **Boils water** — Sometimes remembers to turn off stove.
+2. **Basic meals** — Eggs and pasta. The two food groups.
+3. **Follows recipes** — To the letter. Substitution is heresy.
+4. **Home cook** — Friends don't dread your dinner invitations.
+5. **Experiments** — Sometimes works. You pretend you planned it.
+6. **Hosts dinners** — Spice rack has subdivisions.
+7. **Chef level** — Own a mandoline, all fingers intact.
+
+---
+
+### g — Geek Specialization
+
+Mandatory. `/` separates domains. `~` prefixes custom domains. `$` after a domain = paid. No ratings.
+
+#### Computing & Tech
+
+| Code | Domain | Code | Domain |
+|---|---|---|---|
+| `cs` | Computer Science | `ai` | Artificial Intelligence |
+| `ml` | Machine Learning | `cy` | Cybersecurity |
+| `ds` | Data Science | `bc` | Blockchain |
+| `io` | Internet of Things | `qc` | Quantum Computing |
+| `vr` | Virtual Reality | `os` | Operating Systems |
+| `dv` | DevOps | `ap` | API Development |
+| `cl` | Cloud Infrastructure | `eg` | Edge Computing |
+| `sc` | Ethical Hacking | `ux` | User Experience |
+| `wp` | Web Performance | `db` | Databases |
+| `em` | Embedded Systems | `fp` | Functional Programming |
+| `nx` | Networking | `cv` | Computer Vision |
+| `nl` | NLP / Comp. Linguistics | `cr` | Cryptography |
+
+#### Science & Medicine
+
+| Code | Domain | Code | Domain |
+|---|---|---|---|
+| `bi` | Biology | `cm` | Chemistry |
+| `ph` | Physics | `md` | Medicine |
+| `nr` | Neuroscience | `ev` | Environmental |
+| `fs` | Forensics | `fd` | Food Science |
+| `as` | Astronomy | `gl` | Geology |
+| `bt` | Biotech | `gn` | Genetics |
+
+#### Engineering & Making
+
+| Code | Domain | Code | Domain |
+|---|---|---|---|
+| `ee` | Electrical Eng. | `me` | Mechanical Eng. |
+| `rb` | Robotics | `dy` | DIY / Maker |
+| `tp` | 3D Printing | `ar` | Arduino |
+| `ce` | Civil Eng. | `ae` | Aerospace Eng. |
+| `am` | Additive Mfg. | `ct` | Control Systems |
+
+#### Academic & Humanities
+
+| Code | Domain | Code | Domain |
+|---|---|---|---|
+| `mt` | Mathematics | `pl` | Philosophy |
+| `ed` | Education | `lb` | Library Science |
+| `lw` | Law | `ss` | Social Science |
+| `py` | Psychology | `cg` | Cognitive Science |
+| `li` | Linguistics | `hs` | History |
+| `an` | Anthropology | `ec` | Economics |
+
+#### Art & Design
+
+| Code | Domain | Code | Domain |
+|---|---|---|---|
+| `gd` | Graphic Design | `ad` | Animation |
+| `fa` | Fine Arts | `mu` | Music |
+| `pa` | Performing Arts | `sr` | Screenwriting |
+| `tw` | Tech Writing | `wr` | Writing |
+| `vd` | Game Design | `pg` | Photography |
+| `tx` | Textile Arts | `ty` | Typography |
+
+#### Business & Comms
+
+`bz` Business · `mk` Marketing · `gv` Government · `lo` Logistics · `qa` QA · `pm` Project Mgmt · `hr` HR
+
+#### Culture & Society
+
+`mm` Memes · `fn` Fandom · `pp` Pop Culture · `gn` Gender Studies · `al` Alt Lifestyles
+
+#### Games & Media
+
+`rp` RPG · `sm` Streaming · `pd` Podcasting · `cw` Cosplay
+
+#### Futurism
+
+`ax` Aerospace · `ft` Futurism · `ali` Alien Theories · `tr` Transhumanism
+
+#### Custom
+
+`~fermenting` `~beekeeping` `~lockpicking` `~origami` — anything goes.
+
+> `gcs$/ai/wr~fermenting` — paid CS geek, also into AI, writing, and fermenting.
+
+---
+
+### h — Hair
+
+| Short | Name |
+|---|---|
+| `h` | Head |
+| `f` | Face / beard |
+| `b` | Brows |
+| `m` | Mustache |
+| `s` | Sideburns |
+
+0. **Alopecia**
+1. **Shaved**
+2. **Stubble**
+3. **Below average**
+4. **Average**
+5. **Above average**
+6. **Impressive**
+7. **Sasquatch**
+
+---
+
+### i — AI Stance
+
+0. **Butlerian Jihadist** — Would unplug every GPU on earth.
+1. **Strongly opposed** — 'No AI' in bio, code, and dating profile.
+2. **Skeptical** — Prefer human-authored everything.
+3. **Cautious** — Ethics papers keep you up at night.
+4. **Neutral tool** — Like a hammer. No opinions about hammers.
+5. **Positive** — Copilot saved 20 minutes today.
+6. **Enthusiast** — Cyborg partnership workflow.
+7. **Singularity evangelist** — Grocery list is AI-generated.
+
+---
+
+### j — Gaming
+
+**Genre:**
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `rp` | `(rpg)` | RPG | `fp` | `(fps)` | Shooter |
+| `st` | `(str)` | Strategy | `pz` | `(pzl)` | Puzzle |
+| `si` | `(sim)` | Simulation | `mm` | `(mmo)` | MMO |
+| `ad` | `(adv)` | Adventure | `sp` | `(spo)` | Sports |
+| `mb` | `(mba)` | Metroidvania | `ro` | `(rog)` | Roguelike |
+| `su` | `(surv)` | Survival | `sa` | `(sand)` | Sandbox |
+| `rr` | `(race)` | Racing | `fi` | `(fight)` | Fighting |
+| `ho` | `(horror)` | Horror | | | |
+
+**Platform:**
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `pc` | — | PC | `ps` | — | PlayStation |
+| `xb` | `(xbox)` | Xbox | `ns` | `(switch)` | Switch |
+| `mo` | `(mobile)` | Mobile | `re` | `(retro)` | Retro |
+| `vr` | — | VR | `sd` | `(steamdeck)` | Steam Deck |
+
+**Tabletop:**
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `dn` | `(dnd)` | D&D | `pf` | `(path)` | Pathfinder |
+| `cc` | `(coc)` | Call of Cthulhu | `sr` | — | Shadowrun |
+| `wo` | `(wod)` | World of Darkness | `ft` | `(fate)` | Fate |
+| `gu` | `(gurps)` | GURPS | `bg` | — | Board games |
+| `mt` | `(mtg)` | Magic: TG | `wh` | — | Warhammer |
+
+Scale: **enthusiasm** (see global scale table).
+
+> 0: Video games are a waste of time.
+> 7: People disconnect when they see your name.
+
+---
+
+### k — Books
+
+0. **Illiterate by choice** — Words are tiny drawings that haven't tried hard enough.
+1. **Not since school** — Didn't finish that one either.
+2. **Web only** — You call this 'reading.'
+3. **Occasional** — Mostly when trapped on a plane.
+4. **Few/year** — Goodreads updated sporadically.
+5. **Regular** — To-read pile doubles as furniture.
+6. **Avid** — Library cards from multiple jurisdictions.
+7. **Book/week** — Shelves have shelves. Read this spec for fun.
+
+---
+
+### l — Spoken Languages
+
+ISO 639-1 codes + proficiency 0–7. `$` and `+` permitted.
+
+0. **None**
+1. **Few words**
+2. **Tourist**
+3. **Beginner**
+4. **Conversational**
+5. **Proficient**
+6. **Fluent**
+7. **Native**
+
+Common codes: `en` English · `es` Spanish · `fr` French · `de` German · `zh` Chinese · `ar` Arabic · `ja` Japanese · `ko` Korean · `pt` Portuguese · `ru` Russian · `hi` Hindi · `it` Italian · `nl` Dutch · `sv` Swedish · `tr` Turkish · `pl` Polish · `uk` Ukrainian · `fa` Farsi · `he` Hebrew · `th` Thai · `vi` Vietnamese · `id` Indonesian · `ms` Malay · `sw` Swahili · `bn` Bengali · `ta` Tamil
+
+> `lar7$en6de3+5` — native Arabic (paid translator), fluent English, learning German (beginner, aspiring proficient).
+
+---
+
+### m — Music
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `rk` | `(rock)` | Rock | `mt` | `(metal)` | Metal |
+| `pk` | `(punk)` | Punk | `jz` | `(jazz)` | Jazz |
+| `bl` | `(blues)` | Blues | `fk` | `(folk)` | Folk |
+| `cl` | `(clas)` | Classical | `el` | `(elec)` | Electronic |
+| `hp` | `(hip)` | Hip-hop | `pp` | `(pop)` | Pop |
+| `rb` | `(rnb)` | R&B | `cn` | `(coun)` | Country |
+| `ab` | `(amb)` | Ambient | `pg` | `(prog)` | Progressive |
+| `rg` | `(reg)` | Reggae | `in` | `(ind)` | Indie |
+| `sl` | `(soul)` | Soul | `lt` | `(lat)` | Latin |
+| `wr` | `(wrld)` | World | `dk` | `(dnb)` | Drum & Bass |
+| `tr` | `(trap)` | Trap | `lo` | `(lofi)` | Lo-fi |
+| `sy` | `(synth)` | Synthwave | `go` | `(gospel)` | Gospel |
+| `op` | `(opera)` | Opera | `ga` | `(garage)` | Garage |
+
+Scale: **enthusiasm** (see global scale table).
+
+> 0: This genre is an assault on sound.
+> 7: This genre IS your personality.
+
+---
+
+### o — Operating System
+
+**Linux (x prefix):**
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `xa` | `(xarch)` | Arch | `xd` | `(xdeb)` | Debian |
+| `xu` | `(xubu)` | Ubuntu | `xm` | `(xmint)` | Mint |
+| `xp` | `(xpop)` | Pop!_OS | `xf` | `(xfed)` | Fedora |
+| `xr` | `(xrh)` | Red Hat | `xc` | `(xcent)` | CentOS/Alma/Rocky |
+| `xs` | `(xsuse)` | OpenSUSE | `xg` | `(xgen)` | Gentoo |
+| `xj` | `(xmanj)` | Manjaro | `xsl` | `(xslk)` | Slackware |
+| `xn` | `(xnix)` | NixOS | `xk` | `(xkali)` | Kali |
+| `xv` | `(xvoid)` | Void | `xz` | `(xzor)` | Zorin |
+| `xx` | `(xmx)` | MX Linux | `xt` | `(xtail)` | Tails |
+| `xq` | `(xqube)` | Qubes | `xl` | `(xlfs)` | LFS |
+| `xe` | `(xelem)` | Elementary | `xw` | `(xwsl)` | WSL |
+| `xi` | `(ximmut)` | Immutable | `xb` | `(xbedrock)` | Bedrock |
+
+**BSD:**
+`fb` FreeBSD · `ob` OpenBSD · `nb` NetBSD · `db` DragonflyBSD
+
+**Windows:**
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `wt` | `(wten)` | Windows 10 | `we` | `(welv)` | Windows 11 |
+| `ws` | `(wsrv)` | Win Server | | | |
+
+**Mac:**
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `mc` | `(mac)` | macOS | | | |
+
+**Mobile:**
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `an` | `(android)` | Android | `io` | `(ios)` | iOS |
+| `pm` | `(pmos)` | postmarketOS | `li` | `(lineage)` | LineageOS |
+
+**Other:**
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `sl` | `(sol)` | Solaris | `ax` | `(aix)` | AIX |
+| `ch` | `(chrome)` | ChromeOS | `ha` | `(haiku)` | Haiku |
+| `tm` | `(temple)` | TempleOS | `se` | `(serenity)` | SerenityOS |
+| `rx` | `(redox)` | Redox | `pl` | `(plan9)` | Plan 9 |
+
+Scale: **proficiency** (see global scale table).
+
+> 0: Would mass-format every drive running this.
+> 7: You ARE the sysadmin. First person plural.
+
+---
+
+### p — Programming Languages
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `py` | `(python)` | Python | `rs` | `(rust)` | Rust |
+| `c` | — | C | `cp` | `(cpp)` | C++ |
+| `cs` | `(csharp)` | C# | `jv` | `(java)` | Java |
+| `kt` | `(kotlin)` | Kotlin | `sw` | `(swift)` | Swift |
+| `ph` | `(php)` | PHP | `pl` | `(perl)` | Perl |
+| `lu` | `(lua)` | Lua | `hs` | `(haskell)` | Haskell |
+| `cj` | `(clj)` | Clojure | `er` | `(erlang)` | Erlang |
+| `ex` | `(elixir)` | Elixir | `sc` | `(scala)` | Scala |
+| `jl` | `(julia)` | Julia | `r` | — | R |
+| `m` | `(matlab)` | MATLAB | `zg` | `(zig)` | Zig |
+| `ni` | `(nim)` | Nim | `as` | `(asm)` | Assembly |
+| `sq` | `(sql)` | SQL | `ba` | `(bash)` | Bash |
+| `ps` | `(psh)` | PowerShell | `vb` | — | Visual Basic |
+| `da` | `(dart)` | Dart | `oc` | `(objc)` | Obj-C |
+| `ls` | `(lisp)` | Lisp | `js` | — | JavaScript |
+| `ts` | — | TypeScript | `go` | — | Go |
+| `rb` | `(ruby)` | Ruby | `ml` | `(ocaml)` | OCaml |
+| `fs` | `(fsharp)` | F# | `pr` | `(prolog)` | Prolog |
+| `co` | `(cobol)` | COBOL | `fo` | `(fortran)` | Fortran |
+| `gd` | `(gdscript)` | GDScript | `vl` | `(vhdl)` | VHDL |
+| `sv` | `(sysv)` | SystemVerilog | `cr` | `(crystal)` | Crystal |
+| `hy` | `(hylang)` | Hy | `rk` | `(racket)` | Racket |
+| `tc` | `(tcl)` | Tcl | `aw` | `(awk)` | AWK |
+| `sd` | `(sed)` | sed | `wm` | `(wasm)` | WASM |
+
+Scale: **proficiency** (see global scale table).
+
+> 0: Mass-produced negative opinions without writing a line.
+> 7: You ARE the language. People cite your blog posts.
+
+---
+
+### q — Security / Hacking
+
+0. **No security** — Password is 'password'. Click every link.
+1. **Don't care** — Firewalls are a construction thing.
+2. **Default passwords** — Pet name plus birth year. Reused everywhere.
+3. **Basic awareness** — Know phishing is bad. Fallen for it once.
+4. **Password manager** — Normal human security.
+5. **2FA everywhere** — Judge friends' passwords.
+6. **Encrypts everything** — Paranoid but justified.
+7. **Tinfoil hat** — NSA has a file but can't read it either.
+
+---
+
+### r — Religion
+
+**Single value only** — one tradition + devoutness rating. Parser rejects multiple sub-IDs.
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `ch` | `(chr)` | Christian | `is` | `(isl)` | Islam |
+| `ju` | `(jud)` | Judaism | `hi` | `(hin)` | Hindu |
+| `bu` | `(bud)` | Buddhist | `sk` | `(sik)` | Sikh |
+| `pg` | `(pag)` | Pagan | `de` | `(dei)` | Deist |
+| `ag` | `(agn)` | Agnostic | `at` | `(ath)` | Atheist |
+| `pn` | `(pan)` | Pantheist | `sp` | `(spi)` | Spiritual |
+| `zo` | `(zor)` | Zoroastrian | `ja` | `(jain)` | Jain |
+| `ta` | `(tao)` | Taoist | `sh` | `(shin)` | Shinto |
+| `ba` | `(bah)` | Bahá'í | `dr` | `(druz)` | Druze |
+
+Rating = devoutness: 0 = Hostile, 4 = Cultural, 7 = Clergy-level.
+
+> `ris6` — devout Muslim. `rath4` — culturally atheist.
+
+---
+
+### s — Sex / Relationships
+
+0. **Celibate** — Made your peace.
+1. **Not active (unwilling)** — Dating profile is unrewarded effort.
+2. **Not active** — 'Focusing on yourself.' Friends skeptical.
+3. **Complicated** — Status requires a flowchart.
+4. **Private** — None of your business.
+5. **Dating** — At least two dating apps.
+6. **Partnered** — Found someone who tolerates your geekery.
+7. **Married w/ kids** — Evidence is small, loud, doesn't sleep.
+
+---
+
+### t — TV / Film
+
+**Sci-Fi:**
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `st` | `(trek)` | Star Trek | `sw` | `(wars)` | Star Wars |
+| `sg` | `(gate)` | Stargate | `ex` | `(exp)` | Expanse |
+| `bs` | `(bsg)` | Battlestar | `bb` | `(bab)` | Babylon 5 |
+| `ff` | — | Firefly | `du` | `(dune)` | Dune |
+| `fn` | `(fnd)` | Foundation | `dw` | `(drwho)` | Doctor Who |
+| `tb` | `(tbp)` | 3 Body Problem | `si` | `(silo)` | Silo |
+| `fa` | `(fall)` | Fallout | `al` | `(alien)` | Alien |
+| `bd` | `(blade)` | Blade Runner | `or` | `(orville)` | Orville |
+
+**Fantasy:**
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `go` | `(got)` | Game of Thrones | `wo` | `(wot)` | Wheel of Time |
+| `ro` | `(rop)` | Rings of Power | `wi` | `(wit)` | Witcher |
+| `sn` | `(sand)` | Sandman | | | |
+
+**Other:**
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `mp` | `(monty)` | Monty Python | `bt` | `(bbt)` | Big Bang Theory |
+| `rm` | — | Rick & Morty | `bl` | — | Black Mirror |
+| `sv` | `(sev)` | Severance | `mr` | `(mrobot)` | Mr. Robot |
+| `hm` | `(humans)` | Humans | `ww` | `(westworld)` | Westworld |
+| `dp` | `(devs)` | Devs | | | |
+
+Scale: **enthusiasm** (see global scale table).
+
+> 0: Crime against storytelling.
+> 7: Know the lore deeper than the writers.
+
+---
+
+### v — Politics
+
+Format: `v<social>/<economic>`. No modifiers.
+
+**Social:** 0 = Far-right authoritarian · 4 = Centrist · 7 = Far-left libertarian
+**Economic:** 0 = Unregulated capitalism · 4 = Mixed economy · 7 = Full socialism
+
+> `v5/3` — socially center-left, economically center-right.
+> `v4/4` — centrist. You annoy both sides equally.
+
+---
+
+### w — Hardware
+
+0. **Defeated by USB** — The cable won.
+1. **Can plug in** — Big hole is for power.
+2. **Replaced a part** — Three YouTube videos and mild weeping.
+3. **Troubleshoots** — Can tell RAM from a graphics card.
+4. **Built a PC** — Cable management isn't great but it posted.
+5. **Comfortable** — Friends call when things break.
+6. **Mods regularly** — Void warranties recreationally.
+7. **Builds everything** — Manufactured your own PCBs.
+
+---
+
+### x — Comics / Manga
+
+| Short | (Long) | Name | Short | (Long) | Name |
+|---|---|---|---|---|---|
+| `dc` | — | DC Comics | `mv` | `(marvel)` | Marvel |
+| `mg` | `(manga)` | Manga | `dl` | `(dilbert)` | Dilbert |
+| `xk` | `(xkcd)` | XKCD | `in` | `(indie)` | Indie |
+| `wt` | `(webtoon)` | Webtoon | `gn` | `(graphicnovel)` | Graphic Novels |
+
+Scale: **enthusiasm** (see global scale table).
+
+> 0: Comics are for children. (You're wrong.)
+> 7: Collection requires its own room.
+
+---
+
+### y — Fitness
+
+0. **What is outside** — Cardiovascular exercise is walking to the fridge.
+1. **Avoids activity** — Physical activity is a social obligation.
+2. **Occasional walk** — Coffee is the point, not the walk.
+3. **Light activity** — Own athletic wear. Never been to a gym.
+4. **Some exercise** — Can climb stairs without existential crisis.
+5. **Regular workouts** — Track your steps.
+6. **Very active** — Resting heart rate impresses doctors.
+7. **Daily athlete** — Body is a temple. Temple has a squat rack.
+
+---
+
+### z — Maker / DIY
+
+0. **Defeated by IKEA** — Allen keys are your nemesis.
+1. **IKEA once** — One screw left over. Probably fine.
+2. **Basic repairs** — Holes approximately where they should be.
+3. **Handy** — Fixes hold. Duct tape is structural.
+4. **Simple projects** — A shelf here, a birdhouse there.
+5. **Regular maker** — Projects folder never shrinks.
+6. **Advanced** — Workshop has zones.
+7. **Built my house** — Hardware store sends Christmas card.
+
+---
+## Extending UGI
+
+Any field with sub-IDs accepts custom entries via `~`:
+
+```
+gcs/~mycology geek of CS and mycology
+m~synthwave6 synthwave enthusiast
+t~severance7 severance obsessed
+j~larp5 LARP, competent
+o~beos4 BeOS, neutral nostalgia
+```
+
+Letters `n` and `u` are reserved for future spec versions.
+
+---
+## Examples
+
+### jdoe — Full-stack developer
+
+```
+------- BEGIN UGI BLOCK -------
+v:0 @jdoe Gcs$/dev
+A4 E4 Len7
+Oxa6 Owe5 Ppy6$ Pjs6$ Pts5 Prs4+6 Dvs6 W5 Q4
+I5 V5/4 Rag4
+Tst5 Tex7 K5 Xxk6 Jst5 Jpc6 Mrk5 Min4
+B5/4 C3 Hh4 Hf5 Hm4 S6 Y4 F4 Z3
+-------- END UGI BLOCK --------
+```
+
+```
+ugi:0@jdoe:gcs$/dev,a4,b5/4,c3,hh4f5m4,e4,len7,oxa6we5,ppy6$js6$ts5rs4+6,dvs6,w5,q4,i5,v5/4,rag4,tst5ex7,k5,xxk6,jst5pc6,mrk5in4,s6,y4,f4,z3
+```
+
+### neo — Security researcher
+
+```
+------- BEGIN UGI BLOCK -------
+v:0 @neo Gcy$/sec/os
+A3 E6 Len6 Lzh4
+Oxd7$ Ofb6 Pc6$ Pba7 Pas5 Prs5 Ded7 W6 Q7$
+I1 V6/5 Rat5
+Tff7 Tbs6 Tbl6 K7 Jro6 Jpc7 Mmt6 Mel5
+B5/3 C1 Hh2 S2 Y5 F3 Z6
+-------- END UGI BLOCK --------
+```
+
+```
+ugi:0@neo:gcy$/sec/os,a3,b5/3,c1,hh2,e6,len6zh4,oxd7$fb6,pc6$ba7as5rs5,ded7,w6,q7$,i1,v6/5,rat5,tff7bs6bl6,k7,jro6pc7,mmt6el5,s2,y5,f3,z6
+```
+
+### hax0r — Hardware hacker
+
+```
+------- BEGIN UGI BLOCK -------
+v:0/3 @hax0r Gee$/rb/dy~solder
+A5 E4 Lja7 Len5
+Oxg7 Ofb5 Pc5 Pba6 Pas7$ Dem5 W7$ Q6
+I2 V3/3 Rbu5
+Tbb6 Tff7 K5 Xxk6 Xdl5 Jre7 Jpc6 Jbg5 Mmt7 Mpk6
+B6/5 C2 Hh3 Hf6+7 Hm5 S3 Y5 F4 Z7$
+-------- END UGI BLOCK --------
+```
+
+```
+ugi:0/3@hax0r:gee$/rb/dy~solder,a5,b6/5,c2,hh3f6+7m5,e4,lja7en5,oxg7fb5,pc5ba6as7$,dem5,w7$,q6,i2,v3/3,rbu5,tbb6ff7,k5,xxk6dl5,jre7pc6bg5,mmt7pk6,s3,y5,f4,z7$
+```
+
+---
+## Comparison with predecessors
+
+| | Geek Code 3.12 (1996) | Geek Code 2026 | Hacker Key v4 (2006) | UGI v0 |
+|---|---|---|---|---|
+| Rating | +/- stacking | +/- stacking | 0-9 | 0-7 octal |
+| Length | 4-6 lines | 4-8 lines | 1 line | 1 or 4-8 |
+| Fields | ~25 | ~35+ | ~16 | 24+2 |
+| URI-safe | No | No | Mostly | Yes |
+| Case | Sensitive | Sensitive | Sensitive | Insensitive |
+| Extensible | No | No | Limited | ~ custom |
+| Format | Block | Block | Inline | Block+URI |
+| Active | 1996 | GitHub | 2006 | Draft |
+
+### Fields retained
+
+`a` age brackets, `b` dimensions, `c` dress/clothing, `e` education, `g` geek type, expanded, `h` hair/beard, `k` books, `o` os merged, `p` programming, `q` security/pgp, `s` sex, `t` tv merged, `v` politics, `w` hardware — all present in one or both predecessors.
+
+### Fields added
+
+`d` — Editor war must be documented.
+`f` — Geeks cook now.
+`i` — Defining divide of the 2020s.
+`j` — Gaming beyond DOOM.
+`l` — Geek culture is global.
+`m` — Absent from predecessors.
+`r` — From Hacker Key.
+`x` — Beyond Dilbert.
+`y` — Modern geek exercises.
+`z` — Maker movement.
+
+### Fields removed
+
+Usenet — dead. Kibo — niche. DOOM — use j. OS/2, VMS — use o+~custom. MBTI — pop psychology. Residence — low value. GitHub handle — @handle. I/O — abstract. Cracking — use q. Lang hacking — in g. Math level — in e+g. Prog methodology — in p.
+
+---
+## Quick reference
+
+```
+FORMAT: ugi:<ver>[/<rev>]@<handle>:<fields>
+SCALE: 0=hostile 1=dislike 2=meh 3=slight- 4=neutral 5=like 6=strong 7=obsessed
+MODIFY: $ = paid + = aspire / = fluctuate,separate ~ = custom
+
+a age b build c clothing d editor / ide e education
+f food / cooking g geek specialization h hair i ai stance j gaming
+k books l spoken languages m music [n reserved] o operating system
+p programming languages q security / hacking r religion s sex / relationships t tv / film
+[u reserved] v politics w hardware x comics / manga y fitness
+z maker / diy
+
+$+ f j l o p q w y z $ only g + only b c d e h k s none a i m r t v x
+```
+
+---
+## References
+
+- [Geek Code (v0.1–latest)](https://sig.codes/geek/) — Robert A. Hayden, 1993-1996
+- [Geek Code 4.0+](https://sig.codes/geek-4.0/) — exarobibliologist, 2019-2026
+- [Hacker Key (v0.1–latest)](https://sig.codes/hacker-key/) — Chris Allegretta, 2003-2006
+- [sig.codes](https://sig.codes/)
+- [Geek Code on Wikipedia](https://en.wikipedia.org/wiki/Geek_Code)
+- [Geek Code v3.12 mirror](https://geekcode.xyz/)
+- [Hacker Key v4 mirror](https://sig.codes/hacker-key/4/)
+
+---
+
+## License
+
+MIT — see [LICENSE](./LICENSE).
+## Check
+
+- **[ugi.gumx.cc](https://ugi.gumx.cc)** ([repo](https://git.sr.ht/~gumxcc/ugi.gumx.cc)) — web-based UGI encoder, decoder, and format converter
+- **ugi-cli** ([repo](https://git.sr.ht/~gumxcc/ugi-cli)) — command-line tool for encoding, decoding, and converting UGI strings
+
+---
+
+## Generator scripts
+
+The `scripts/` directory contains generators that produce all artifacts from `ugi_registry_v0.json`.
+Each script takes the registry JSON path and output file path as arguments:
+
+```bash
+python scripts/gen_spec.py ugi_registry_v0.json README.md
+python scripts/gen_abnf.py ugi_registry_v0.json grammar/UGI_GRAMMAR_v0.abnf
+python scripts/gen_ebnf.py ugi_registry_v0.json grammar/UGI_GRAMMAR_v0.ebnf
+```
+
+### Requirements
+
+- Python 3.8+
+- No external dependencies
+
+### Project structure
+
+```
+ugi-spec/
+├── ugi_registry_v0.json # source of truth
+├── scripts/
+│ ├── gen_spec.py # spec markdown generator
+│ ├── gen_abnf.py # ABNF grammar generator
+│ └── gen_ebnf.py # EBNF grammar generator
+├── grammar/
+│ ├── UGI_GRAMMAR_v0.abnf # generated ABNF
+│ └── UGI_GRAMMAR_v0.ebnf # generated EBNF
+├── tests/
+│ └── cases.json # test cases for all field types and formats
+└── README.md # this file (generated spec + usage)
+```
+
+---
+
+## License
+
+MIT — see [LICENSE](./LICENSE).
+
diff --git a/spec/grammar/UGI_GRAMMAR_v0.abnf b/spec/grammar/UGI_GRAMMAR_v0.abnf
new file mode 100644
index 0000000..d0f7da1
--- /dev/null
+++ b/spec/grammar/UGI_GRAMMAR_v0.abnf
@@ -0,0 +1,148 @@
+; UGI Grammar v0 — RFC 5234 ABNF
+; Generated from ugi_registry_v0.json
+;
+; Compatible with abnf.dev/abnf2svg
+
+; === Top-level ===
+
+ugi-string = "ugi:" version "@" handle ":" field-list
+
+version = 1*DIGIT ["/" 1*DIGIT]
+
+; handle: alphanumeric, hyphens, underscores, dots
+handle = 1*(ALPHA / DIGIT / %x2D / %x5F / %x2E)
+
+field-list = field *("," field)
+
+; === Field dispatch ===
+
+field = field-a / field-b / field-c / field-d / field-e / field-f / field-g / field-h / field-i / field-j / field-k / field-l / field-m / field-o / field-p / field-q / field-r / field-s / field-t / field-v / field-w / field-x / field-y / field-z
+
+; === Common rules ===
+
+ODIGIT = %x30-37
+
+paid = "$"
+
+aspire = "+" ODIGIT
+
+paid-aspire = paid [aspire]
+
+modifier = paid-aspire / aspire
+
+alternative = "/" ODIGIT
+
+; custom sub-ID: ~ followed by 2+ alpha chars
+custom-sub = "~" 2*ALPHA
+
+; sub-ID: 1-4 alpha chars
+sub-id = 1*4ALPHA
+
+; === Per-field rules ===
+
+; a — Age (direct)
+field-a = %x61 ODIGIT
+
+; b — Build (special)
+field-b = %x62 ODIGIT [aspire] "/" ODIGIT [aspire]
+
+; c — Clothing (direct)
+field-c = %x63 ODIGIT [alternative / aspire]
+
+; d — Editor / IDE (multi)
+field-d-entry = (sub-id / custom-sub) ODIGIT [aspire]
+field-d = %x64 1*field-d-entry
+
+; e — Education (direct)
+field-e = %x65 ODIGIT [aspire]
+
+; f — Food / Cooking (direct)
+field-f = %x66 ODIGIT [modifier]
+
+; g — Geek Specialization (special)
+g-domain = 2*4ALPHA ["$"]
+g-custom = "~" 2*ALPHA
+field-g = %x67 (g-domain / g-custom) *("/" (g-domain / g-custom))
+
+; h — Hair (multi)
+field-h-entry = (sub-id / custom-sub) ODIGIT [aspire]
+field-h = %x68 1*field-h-entry
+
+; i — AI Stance (direct)
+field-i = %x69 ODIGIT
+
+; j — Gaming (multi)
+field-j-entry = (sub-id / custom-sub) ODIGIT [modifier]
+field-j = %x6A 1*field-j-entry
+
+; k — Books (direct)
+field-k = %x6B ODIGIT [aspire]
+
+; l — Spoken Languages (multi)
+field-l-entry = 2ALPHA ODIGIT [modifier]
+field-l = %x6C 1*field-l-entry
+
+; m — Music (multi)
+field-m-entry = (sub-id / custom-sub) ODIGIT
+field-m = %x6D 1*field-m-entry
+
+; o — Operating System (multi)
+field-o-entry = (sub-id / custom-sub) ODIGIT [modifier]
+field-o = %x6F 1*field-o-entry
+
+; p — Programming Languages (multi)
+field-p-entry = (sub-id / custom-sub) ODIGIT [modifier]
+field-p = %x70 1*field-p-entry
+
+; q — Security / Hacking (direct)
+field-q = %x71 ODIGIT [alternative / modifier]
+
+; r — Religion (single)
+field-r = %x72 (sub-id / custom-sub) ODIGIT
+
+; s — Sex / Relationships (direct)
+field-s = %x73 ODIGIT [alternative / aspire]
+
+; t — TV / Film (multi)
+field-t-entry = (sub-id / custom-sub) ODIGIT
+field-t = %x74 1*field-t-entry
+
+; v — Politics (special)
+field-v = %x76 ODIGIT "/" ODIGIT
+
+; w — Hardware (direct)
+field-w = %x77 ODIGIT [modifier]
+
+; x — Comics / Manga (multi)
+field-x-entry = (sub-id / custom-sub) ODIGIT
+field-x = %x78 1*field-x-entry
+
+; y — Fitness (direct)
+field-y = %x79 ODIGIT [modifier]
+
+; z — Maker / DIY (direct)
+field-z = %x7A ODIGIT [modifier]
+
+; === Block format ===
+
+ugi-block = block-header CRLF block-first-line CRLF 1*(block-field-line CRLF) block-footer
+
+block-header = "------- BEGIN UGI BLOCK -------"
+block-footer = "-------- END UGI BLOCK --------"
+
+block-first-line = "v:" version SP "@" handle [SP block-geek-field]
+
+; In block format, G field uses uppercase and appears on first line
+block-geek-field = "G" (g-domain / g-custom) *("/" (g-domain / g-custom))
+
+block-field-line = block-field *(SP block-field)
+
+; Block fields use uppercase code prefix repeated per sub-ID
+block-field = ALPHA 1*(ALPHA / DIGIT / %x24 / %x2B / %x2F / %x7E)
+
+; === Core rules (RFC 5234) ===
+
+ALPHA = %x41-5A / %x61-7A
+DIGIT = %x30-39
+SP = %x20
+CRLF = %x0D.0A / %x0A
diff --git a/spec/grammar/UGI_GRAMMAR_v0.ebnf b/spec/grammar/UGI_GRAMMAR_v0.ebnf
new file mode 100644
index 0000000..e37f87f
--- /dev/null
+++ b/spec/grammar/UGI_GRAMMAR_v0.ebnf
@@ -0,0 +1,167 @@
+/* UGI Grammar v0 — W3C EBNF */
+/* Generated from ugi_registry_v0.json */
+/* Compatible with bottlecaps.de/rr/ui */
+
+/* === Top-level === */
+
+ugi_string ::= 'ugi:' version '@' handle ':' field_list
+
+version ::= digit+ ( '/' digit+ )?
+
+/* handle: alphanumeric, hyphens, underscores, dots */
+handle ::= ( alpha | digit | '-' | '_' | '.' )+
+
+field_list ::= field ( ',' field )*
+
+/* === Field dispatch === */
+
+field ::= field_a
+ | field_b
+ | field_c
+ | field_d
+ | field_e
+ | field_f
+ | field_g
+ | field_h
+ | field_i
+ | field_j
+ | field_k
+ | field_l
+ | field_m
+ | field_o
+ | field_p
+ | field_q
+ | field_r
+ | field_s
+ | field_t
+ | field_v
+ | field_w
+ | field_x
+ | field_y
+ | field_z
+
+/* === Common rules === */
+
+odigit ::= [0-7]
+
+paid ::= '$'
+
+aspire ::= '+' odigit
+
+paid_aspire ::= paid aspire?
+
+modifier ::= paid_aspire | aspire
+
+alternative ::= '/' odigit
+
+/* custom sub-ID: ~ followed by 2+ alpha chars */
+custom_sub ::= '~' alpha alpha alpha*
+
+/* sub-ID: 1-4 alpha chars */
+sub_id ::= alpha alpha? alpha? alpha?
+
+/* === Per-field rules === */
+
+/* a — Age (direct) */
+field_a ::= 'a' odigit
+
+/* b — Build (special) */
+field_b ::= 'b' odigit aspire? '/' odigit aspire?
+
+/* c — Clothing (direct) */
+field_c ::= 'c' odigit ( alternative | aspire )?
+
+/* d — Editor / IDE (multi) */
+field_d_entry ::= ( sub_id | custom_sub ) odigit ( aspire )?
+field_d ::= 'd' field_d_entry+
+
+/* e — Education (direct) */
+field_e ::= 'e' odigit ( aspire )?
+
+/* f — Food / Cooking (direct) */
+field_f ::= 'f' odigit ( modifier )?
+
+/* g — Geek Specialization (special) */
+g_domain ::= alpha alpha alpha? alpha? paid?
+g_custom ::= '~' alpha alpha alpha*
+field_g ::= 'g' ( g_domain | g_custom ) ( '/' ( g_domain | g_custom ) )*
+
+/* h — Hair (multi) */
+field_h_entry ::= ( sub_id | custom_sub ) odigit ( aspire )?
+field_h ::= 'h' field_h_entry+
+
+/* i — AI Stance (direct) */
+field_i ::= 'i' odigit
+
+/* j — Gaming (multi) */
+field_j_entry ::= ( sub_id | custom_sub ) odigit ( modifier )?
+field_j ::= 'j' field_j_entry+
+
+/* k — Books (direct) */
+field_k ::= 'k' odigit ( aspire )?
+
+/* l — Spoken Languages (multi) */
+field_l_entry ::= alpha alpha odigit ( modifier )?
+field_l ::= 'l' field_l_entry+
+
+/* m — Music (multi) */
+field_m_entry ::= ( sub_id | custom_sub ) odigit
+field_m ::= 'm' field_m_entry+
+
+/* o — Operating System (multi) */
+field_o_entry ::= ( sub_id | custom_sub ) odigit ( modifier )?
+field_o ::= 'o' field_o_entry+
+
+/* p — Programming Languages (multi) */
+field_p_entry ::= ( sub_id | custom_sub ) odigit ( modifier )?
+field_p ::= 'p' field_p_entry+
+
+/* q — Security / Hacking (direct) */
+field_q ::= 'q' odigit ( alternative | modifier )?
+
+/* r — Religion (single) */
+field_r ::= 'r' ( sub_id | custom_sub ) odigit
+
+/* s — Sex / Relationships (direct) */
+field_s ::= 's' odigit ( alternative | aspire )?
+
+/* t — TV / Film (multi) */
+field_t_entry ::= ( sub_id | custom_sub ) odigit
+field_t ::= 't' field_t_entry+
+
+/* v — Politics (special) */
+field_v ::= 'v' odigit '/' odigit
+
+/* w — Hardware (direct) */
+field_w ::= 'w' odigit ( modifier )?
+
+/* x — Comics / Manga (multi) */
+field_x_entry ::= ( sub_id | custom_sub ) odigit
+field_x ::= 'x' field_x_entry+
+
+/* y — Fitness (direct) */
+field_y ::= 'y' odigit ( modifier )?
+
+/* z — Maker / DIY (direct) */
+field_z ::= 'z' odigit ( modifier )?
+
+/* === Block format === */
+
+ugi_block ::= block_header newline block_first_line newline block_field_line+ block_footer
+
+block_header ::= '------- BEGIN UGI BLOCK -------'
+block_footer ::= '-------- END UGI BLOCK --------'
+
+block_first_line ::= 'v:' version ' @' handle ( ' ' block_geek_field )?
+
+block_geek_field ::= 'G' ( g_domain | g_custom ) ( '/' ( g_domain | g_custom ) )*
+
+block_field_line ::= block_field ( ' ' block_field )* newline
+
+block_field ::= alpha ( alpha | digit | '$' | '+' | '/' | '~' )+
+
+/* === Terminals === */
+
+alpha ::= [a-zA-Z]
+digit ::= [0-9]
+newline ::= #xD #xA | #xA
diff --git a/spec/scripts/gen_abnf.py b/spec/scripts/gen_abnf.py
new file mode 100644
index 0000000..9569195
--- /dev/null
+++ b/spec/scripts/gen_abnf.py
@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+"""Generate UGI_GRAMMAR_v0.abnf from ugi_registry_v0.json."""
+
+import json
+import os
+import sys
+
+
+def load_registry(path):
+ with open(path, "r") as f:
+ return json.load(f)
+
+
+def hex_char(c):
+ """Return ABNF hex notation for a character."""
+ return f"%x{ord(c):02X}"
+
+
+def hex_range(start, end):
+ """Return ABNF hex range."""
+ return f"%x{ord(start):02X}-{ord(end):02X}"
+
+
+def collect_sub_ids(f):
+ """Collect all sub-ID codes from a field."""
+ ids = []
+ if "sub_ids" in f:
+ ids.extend(f["sub_ids"].keys())
+ if "sub_id_groups" in f:
+ for group in f["sub_id_groups"].values():
+ if "sub_ids" in group:
+ ids.extend(group["sub_ids"].keys())
+ return ids
+
+
+def gen_sub_id_rule(name, sub_ids):
+ """Generate ABNF rule matching any of the given sub-ID strings."""
+ # Quote each as case-insensitive string
+ parts = []
+ for sid in sorted(set(sub_ids)):
+ # ABNF quoted strings are case-insensitive by default
+ parts.append(f'"{sid}"')
+ return f"{name} = " + " / ".join(parts)
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(f"Usage: {sys.argv[0]} <registry.json> <output.abnf>", file=sys.stderr)
+ sys.exit(1)
+
+ registry_path, output_path = sys.argv[1], sys.argv[2]
+ reg = load_registry(registry_path)
+ fields = reg["fields"]
+
+ lines = []
+ lines.append("; UGI Grammar v0 — RFC 5234 ABNF")
+ lines.append("; Generated from ugi_registry_v0.json")
+ lines.append(";")
+ lines.append("; Compatible with abnf.dev/abnf2svg")
+ lines.append("")
+
+ # ── Top-level ──
+ lines.append("; === Top-level ===")
+ lines.append("")
+ lines.append('ugi-string = "ugi:" version "@" handle ":" field-list')
+ lines.append("")
+ lines.append('version = 1*DIGIT ["/" 1*DIGIT]')
+ lines.append("")
+ lines.append("; handle: alphanumeric, hyphens, underscores, dots")
+ lines.append("handle = 1*(ALPHA / DIGIT / %x2D / %x5F / %x2E)")
+ lines.append("")
+ lines.append('field-list = field *("," field)')
+ lines.append("")
+
+ # ── Field dispatch ──
+ lines.append("; === Field dispatch ===")
+ lines.append("")
+
+ # Build field alternatives
+ field_rules = []
+ for code in sorted(fields.keys()):
+ field_rules.append(f"field-{code}")
+
+ lines.append("field = " + " / ".join(field_rules))
+ lines.append("")
+
+ # ── Common rules ──
+ lines.append("; === Common rules ===")
+ lines.append("")
+ lines.append("ODIGIT = %x30-37")
+ lines.append("")
+ lines.append('paid = "$"')
+ lines.append("")
+ lines.append('aspire = "+" ODIGIT')
+ lines.append("")
+ lines.append("paid-aspire = paid [aspire]")
+ lines.append("")
+ lines.append("modifier = paid-aspire / aspire")
+ lines.append("")
+ lines.append('alternative = "/" ODIGIT')
+ lines.append("")
+ lines.append('; custom sub-ID: ~ followed by 2+ alpha chars')
+ lines.append('custom-sub = "~" 2*ALPHA')
+ lines.append("")
+ lines.append("; sub-ID: 1-4 alpha chars")
+ lines.append("sub-id = 1*4ALPHA")
+ lines.append("")
+
+ # ── Per-field rules ──
+ lines.append("; === Per-field rules ===")
+ lines.append("")
+
+ for code in sorted(fields.keys()):
+ f = fields[code]
+ ftype = f["type"]
+ mods = f.get("modifiers", {})
+ has_paid = mods.get("paid", False)
+ has_aspire = mods.get("aspire", False)
+ has_alt = mods.get("alternative", False)
+
+ hex_code = hex_char(code)
+ lines.append(f"; {code} — {f['name']} ({ftype})")
+
+ if ftype == "direct":
+ # Build modifier part
+ mod_parts = []
+ if has_alt:
+ mod_parts.append("alternative")
+ if has_paid and has_aspire:
+ mod_parts.append("modifier")
+ elif has_paid:
+ mod_parts.append("paid")
+ elif has_aspire:
+ mod_parts.append("aspire")
+
+ if mod_parts:
+ mod_str = " [" + " / ".join(mod_parts) + "]"
+ else:
+ mod_str = ""
+
+ lines.append(f"field-{code} = {hex_code} ODIGIT{mod_str}")
+
+ elif ftype == "multi":
+ # Entry: sub-id + digit + optional modifier
+ mod_parts = []
+ if has_paid and has_aspire:
+ mod_parts.append("modifier")
+ elif has_paid:
+ mod_parts.append("paid")
+ elif has_aspire:
+ mod_parts.append("aspire")
+
+ if mod_parts:
+ mod_str = " [" + " / ".join(mod_parts) + "]"
+ else:
+ mod_str = ""
+
+ entry_rule = f"field-{code}-entry"
+ if code == "l":
+ # l uses 2-char ISO 639-1 codes instead of generic sub-ids
+ lines.append(
+ f"{entry_rule} = 2ALPHA ODIGIT{mod_str}"
+ )
+ else:
+ lines.append(
+ f"{entry_rule} = (sub-id / custom-sub) ODIGIT{mod_str}"
+ )
+ lines.append(
+ f"field-{code} = {hex_code} 1*{entry_rule}"
+ )
+
+ elif ftype == "single":
+ mod_parts = []
+ if has_paid and has_aspire:
+ mod_parts.append("modifier")
+ elif has_paid:
+ mod_parts.append("paid")
+ elif has_aspire:
+ mod_parts.append("aspire")
+
+ if mod_parts:
+ mod_str = " [" + " / ".join(mod_parts) + "]"
+ else:
+ mod_str = ""
+
+ lines.append(
+ f"field-{code} = {hex_code} (sub-id / custom-sub) ODIGIT{mod_str}"
+ )
+
+ elif ftype == "special":
+ if code == "g":
+ # g: domains separated by /, $ after domain, ~ for custom, no digits
+ lines.append(f'g-domain = 2*4ALPHA ["$"]')
+ lines.append(f'g-custom = "~" 2*ALPHA')
+ lines.append(
+ f'field-g = {hex_code} (g-domain / g-custom) *("/" (g-domain / g-custom))'
+ )
+ elif code == "b":
+ # b: digit/digit, optional aspire on each
+ aspire_str = " [aspire]" if has_aspire else ""
+ lines.append(
+ f'field-{code} = {hex_code} ODIGIT{aspire_str} "/" ODIGIT{aspire_str}'
+ )
+ elif code == "v":
+ lines.append(
+ f'field-{code} = {hex_code} ODIGIT "/" ODIGIT'
+ )
+ else:
+ # Fallback
+ lines.append(f"field-{code} = {hex_code} 1*(ALPHA / DIGIT / %x24 / %x2B / %x2F / %x7E)")
+
+ lines.append("")
+
+ # ── Block format ──
+ lines.append("; === Block format ===")
+ lines.append("")
+ lines.append(
+ 'ugi-block = block-header CRLF block-first-line CRLF '
+ '1*(block-field-line CRLF) block-footer'
+ )
+ lines.append("")
+ lines.append('block-header = "------- BEGIN UGI BLOCK -------"')
+ lines.append('block-footer = "-------- END UGI BLOCK --------"')
+ lines.append("")
+ lines.append(
+ 'block-first-line = "v:" version SP "@" handle [SP block-geek-field]'
+ )
+ lines.append("")
+ lines.append('; In block format, G field uses uppercase and appears on first line')
+ lines.append(
+ 'block-geek-field = "G" (g-domain / g-custom) *("/" (g-domain / g-custom))'
+ )
+ lines.append("")
+ lines.append(
+ 'block-field-line = block-field *(SP block-field)'
+ )
+ lines.append("")
+ lines.append(
+ "; Block fields use uppercase code prefix repeated per sub-ID"
+ )
+ lines.append(
+ 'block-field = ALPHA 1*(ALPHA / DIGIT / %x24 / %x2B / %x2F / %x7E)'
+ )
+ lines.append("")
+
+ # ── Core rules ──
+ lines.append("; === Core rules (RFC 5234) ===")
+ lines.append("")
+ lines.append("ALPHA = %x41-5A / %x61-7A")
+ lines.append("DIGIT = %x30-39")
+ lines.append("SP = %x20")
+ lines.append("CRLF = %x0D.0A / %x0A")
+ lines.append("")
+
+ output = "\n".join(lines)
+
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
+ with open(output_path, "w") as f:
+ f.write(output)
+
+ print(f"Generated {output_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/spec/scripts/gen_ebnf.py b/spec/scripts/gen_ebnf.py
new file mode 100644
index 0000000..65824bd
--- /dev/null
+++ b/spec/scripts/gen_ebnf.py
@@ -0,0 +1,215 @@
+#!/usr/bin/env python3
+"""Generate UGI_GRAMMAR_v0.ebnf from ugi_registry_v0.json."""
+
+import json
+import os
+import sys
+
+
+def load_registry(path):
+ with open(path, "r") as f:
+ return json.load(f)
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(f"Usage: {sys.argv[0]} <registry.json> <output.ebnf>", file=sys.stderr)
+ sys.exit(1)
+
+ registry_path, output_path = sys.argv[1], sys.argv[2]
+ reg = load_registry(registry_path)
+ fields = reg["fields"]
+
+ lines = []
+ lines.append("/* UGI Grammar v0 — W3C EBNF */")
+ lines.append("/* Generated from ugi_registry_v0.json */")
+ lines.append("/* Compatible with bottlecaps.de/rr/ui */")
+ lines.append("")
+
+ # ── Top-level ──
+ lines.append("/* === Top-level === */")
+ lines.append("")
+ lines.append("ugi_string ::= 'ugi:' version '@' handle ':' field_list")
+ lines.append("")
+ lines.append("version ::= digit+ ( '/' digit+ )?")
+ lines.append("")
+ lines.append("/* handle: alphanumeric, hyphens, underscores, dots */")
+ lines.append("handle ::= ( alpha | digit | '-' | '_' | '.' )+")
+ lines.append("")
+ lines.append("field_list ::= field ( ',' field )*")
+ lines.append("")
+
+ # ── Field dispatch ──
+ lines.append("/* === Field dispatch === */")
+ lines.append("")
+
+ field_alts = [f"field_{code}" for code in sorted(fields.keys())]
+ # Break into multiple lines for readability
+ lines.append("field ::= " + "\n | ".join(field_alts))
+ lines.append("")
+
+ # ── Common rules ──
+ lines.append("/* === Common rules === */")
+ lines.append("")
+ lines.append("odigit ::= [0-7]")
+ lines.append("")
+ lines.append("paid ::= '$'")
+ lines.append("")
+ lines.append("aspire ::= '+' odigit")
+ lines.append("")
+ lines.append("paid_aspire ::= paid aspire?")
+ lines.append("")
+ lines.append("modifier ::= paid_aspire | aspire")
+ lines.append("")
+ lines.append("alternative ::= '/' odigit")
+ lines.append("")
+ lines.append("/* custom sub-ID: ~ followed by 2+ alpha chars */")
+ lines.append("custom_sub ::= '~' alpha alpha alpha*")
+ lines.append("")
+ lines.append("/* sub-ID: 1-4 alpha chars */")
+ lines.append("sub_id ::= alpha alpha? alpha? alpha?")
+ lines.append("")
+
+ # ── Per-field rules ──
+ lines.append("/* === Per-field rules === */")
+ lines.append("")
+
+ for code in sorted(fields.keys()):
+ f = fields[code]
+ ftype = f["type"]
+ mods = f.get("modifiers", {})
+ has_paid = mods.get("paid", False)
+ has_aspire = mods.get("aspire", False)
+ has_alt = mods.get("alternative", False)
+
+ lines.append(f"/* {code} — {f['name']} ({ftype}) */")
+
+ if ftype == "direct":
+ mod_parts = []
+ if has_alt:
+ mod_parts.append("alternative")
+ if has_paid and has_aspire:
+ mod_parts.append("modifier")
+ elif has_paid:
+ mod_parts.append("paid")
+ elif has_aspire:
+ mod_parts.append("aspire")
+
+ if mod_parts:
+ mod_str = " ( " + " | ".join(mod_parts) + " )?"
+ else:
+ mod_str = ""
+
+ lines.append(f"field_{code} ::= '{code}' odigit{mod_str}")
+
+ elif ftype == "multi":
+ mod_parts = []
+ if has_paid and has_aspire:
+ mod_parts.append("modifier")
+ elif has_paid:
+ mod_parts.append("paid")
+ elif has_aspire:
+ mod_parts.append("aspire")
+
+ if mod_parts:
+ mod_str = " ( " + " | ".join(mod_parts) + " )?"
+ else:
+ mod_str = ""
+
+ if code == "l":
+ # l uses 2-char ISO 639-1 codes instead of generic sub-ids
+ lines.append(
+ f"field_{code}_entry ::= alpha alpha odigit{mod_str}"
+ )
+ else:
+ lines.append(
+ f"field_{code}_entry ::= ( sub_id | custom_sub ) odigit{mod_str}"
+ )
+ lines.append(
+ f"field_{code} ::= '{code}' field_{code}_entry+"
+ )
+
+ elif ftype == "single":
+ mod_parts = []
+ if has_paid and has_aspire:
+ mod_parts.append("modifier")
+ elif has_paid:
+ mod_parts.append("paid")
+ elif has_aspire:
+ mod_parts.append("aspire")
+
+ if mod_parts:
+ mod_str = " ( " + " | ".join(mod_parts) + " )?"
+ else:
+ mod_str = ""
+
+ lines.append(
+ f"field_{code} ::= '{code}' ( sub_id | custom_sub ) odigit{mod_str}"
+ )
+
+ elif ftype == "special":
+ if code == "g":
+ lines.append("g_domain ::= alpha alpha alpha? alpha? paid?")
+ lines.append("g_custom ::= '~' alpha alpha alpha*")
+ lines.append(
+ "field_g ::= 'g' ( g_domain | g_custom ) ( '/' ( g_domain | g_custom ) )*"
+ )
+ elif code == "b":
+ aspire_str = " aspire?" if has_aspire else ""
+ lines.append(
+ f"field_b ::= 'b' odigit{aspire_str} '/' odigit{aspire_str}"
+ )
+ elif code == "v":
+ lines.append("field_v ::= 'v' odigit '/' odigit")
+ else:
+ lines.append(
+ f"field_{code} ::= '{code}' ( alpha | digit | '$' | '+' | '/' | '~' )+"
+ )
+
+ lines.append("")
+
+ # ── Block format ──
+ lines.append("/* === Block format === */")
+ lines.append("")
+ lines.append(
+ "ugi_block ::= block_header newline block_first_line newline "
+ "block_field_line+ block_footer"
+ )
+ lines.append("")
+ lines.append("block_header ::= '------- BEGIN UGI BLOCK -------'")
+ lines.append("block_footer ::= '-------- END UGI BLOCK --------'")
+ lines.append("")
+ lines.append(
+ "block_first_line ::= 'v:' version ' @' handle ( ' ' block_geek_field )?"
+ )
+ lines.append("")
+ lines.append(
+ "block_geek_field ::= 'G' ( g_domain | g_custom ) ( '/' ( g_domain | g_custom ) )*"
+ )
+ lines.append("")
+ lines.append("block_field_line ::= block_field ( ' ' block_field )* newline")
+ lines.append("")
+ lines.append(
+ "block_field ::= alpha ( alpha | digit | '$' | '+' | '/' | '~' )+"
+ )
+ lines.append("")
+
+ # ── Terminals ──
+ lines.append("/* === Terminals === */")
+ lines.append("")
+ lines.append("alpha ::= [a-zA-Z]")
+ lines.append("digit ::= [0-9]")
+ lines.append("newline ::= #xD #xA | #xA")
+ lines.append("")
+
+ output = "\n".join(lines)
+
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
+ with open(output_path, "w") as f:
+ f.write(output)
+
+ print(f"Generated {output_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/spec/scripts/gen_spec.py b/spec/scripts/gen_spec.py
new file mode 100644
index 0000000..4ab8a20
--- /dev/null
+++ b/spec/scripts/gen_spec.py
@@ -0,0 +1,1053 @@
+#!/usr/bin/env python3
+"""Generate UGI_SPEC_v0.md from ugi_registry_v0.json."""
+
+import json
+import os
+import sys
+
+
+def load_registry(path):
+ with open(path, "r") as f:
+ return json.load(f)
+
+
+def gen_title(spec):
+ lines = []
+ lines.append(f"# {spec['name']} — {spec['full_name']}")
+ lines.append("")
+ lines.append(
+ f"**Version: {spec['version']} ({spec['status'].title()}) · "
+ f"{spec['date']} · {spec['license']} License**"
+ )
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_overview(spec):
+ lines = []
+ lines.append("## Overview")
+ lines.append("")
+ lines.append(spec["description"])
+ lines.append("")
+ lines.append("It descends from two predecessors:")
+ lines.append("")
+ for p in spec["predecessors"]:
+ line = f"- [{p['name']}]({p['url']}) by {p['author']} ({p['years']})"
+ if "continuation" in p:
+ c = p["continuation"]
+ line += f", with its [{c['name']}]({c['url']}) by {c['author']} ({c['years']})"
+ lines.append(line)
+ lines.append("")
+ lines.append(
+ "UGI takes the cultural breadth of the Geek Code, the numeric compactness "
+ "of the Hacker Key, and adds URI safety, modern fields, and extensibility."
+ )
+ lines.append("")
+ lines.append("### Design goals")
+ lines.append("")
+ lines.append("1. Single-letter field codes — 24 active, 2 reserved")
+ lines.append("2. Octal (0–7) rating scale")
+ lines.append("3. URI-safe characters only — no percent-encoding needed")
+ lines.append("4. Dual format — URI for machines, block for humans")
+ lines.append("5. Case-insensitive")
+ lines.append("6. Extensible via `~` custom sub-IDs")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_formats(fmt):
+ lines = []
+ lines.append("## Formats")
+ lines.append("")
+ lines.append("### URI")
+ lines.append("")
+ lines.append("```")
+ lines.append(fmt["uri"]["template"])
+ lines.append("```")
+ lines.append("")
+ lines.append("### Block")
+ lines.append("")
+ lines.append("```")
+ lines.append(fmt["block"]["header"])
+ lines.append(fmt["block"]["first_line"])
+ lines.append("<field> <field> ...")
+ lines.append(fmt["block"]["footer"])
+ lines.append("```")
+ lines.append("")
+ lines.append(
+ "In block format, field codes are repeated per sub-ID for readability: "
+ "`Ppy6 Prs5` instead of `ppy6rs5`. Long-form sub-ID aliases (noted in "
+ "registries) are permitted in block format."
+ )
+ lines.append("")
+ lines.append("### Conversion")
+ lines.append("")
+ lines.append(
+ "Block → URI: remove header/footer, merge repeated field codes, "
+ "replace spaces with commas, prepend `ugi:`."
+ )
+ lines.append("")
+ lines.append(
+ "URI → Block: split on commas, expand merged sub-IDs by repeating "
+ "field codes, add header/footer."
+ )
+ lines.append("")
+ lines.append(
+ "No modifier or character changes are needed — both formats use identical symbols."
+ )
+ lines.append("")
+ lines.append("### Mandatory fields")
+ lines.append("")
+ mand = ", ".join(f"`{f}`" for f in fmt["mandatory_fields"])
+ lines.append(
+ f"`@handle` and {mand} (geek type) are required. All others are optional."
+ )
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_grammar(fields):
+ lines = []
+ lines.append("## Grammar")
+ lines.append("")
+ lines.append("```")
+ lines.append("<field_code><sub_id><digit>[<modifier>]...")
+ lines.append("```")
+ lines.append("")
+ lines.append("- **Field codes** — single letter (a–z), case-insensitive")
+ lines.append("- **Sub-IDs** — 1–4 lowercase alpha chars")
+ lines.append("- **Ratings** — single octal digit (0–7), required on every sub-ID")
+ lines.append("- **Custom sub-IDs** — `~` prefix: `~fermenting5`")
+ lines.append("- **Modifiers** — after the digit:")
+ lines.append(" - `$` — paid")
+ lines.append(" - `+` followed by digit — aspiring toward that level")
+ lines.append(" - `$+` combined — paid and aspiring")
+ lines.append(
+ '- **Alternatives** — `/` between two digits on direct-value fields: `c3/6`'
+ )
+ lines.append("")
+ lines.append("### Special formats")
+ lines.append("")
+ lines.append("| Field | Format | Example |")
+ lines.append("|---|---|---|")
+ lines.append(
+ "| `g` geek type | `/` separates domains, `~` for custom, `$` for paid, no ratings | `gcs$/ai/wr~fermenting` |"
+ )
+ lines.append("| `b` build | `<height>/<width>` | `b6/3` |")
+ lines.append("| `l` languages | ISO 639-1 code + rating | `lar7en6` |")
+ lines.append("| `v` politics | `<social>/<economic>` | `v5/3` |")
+ lines.append("")
+ lines.append("### Parser algorithm")
+ lines.append("")
+ lines.append(
+ "After each comma or space, read a single letter (field code). Then:"
+ )
+ lines.append("")
+ lines.append(
+ "- `g` → read alpha tokens separated by `/` and `~`-prefixed tokens until delimiter"
+ )
+ lines.append("- `b` → digit, `/`, digit")
+ lines.append("- `v` → digit, `/`, digit")
+ lines.append("- `l` → repeat: 2 alpha + digit + optional modifier")
+ lines.append("- `r` → one sub-id + digit (single value only)")
+ lines.append("- direct fields → digit + optional modifier")
+ lines.append("- direct-alt fields → digit + optional `/` + digit + optional modifier")
+ lines.append("- multi fields → repeat: alpha (sub-id) + digit + optional modifier")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_scale(scale):
+ lines = []
+ lines.append("## Rating scale (0–7)")
+ lines.append("")
+ lines.append("| Value | General | Proficiency | Enthusiasm | Stance |")
+ lines.append("|---|---|---|---|---|")
+ for v in scale["values"]:
+ lines.append(
+ f"| {v['value']} | {v['general']} | {v['proficiency']} | "
+ f"{v['enthusiasm']} | {v['stance']} |"
+ )
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_modifier_permissions(fields, modifiers):
+ lines = []
+ lines.append("### Modifier permissions")
+ lines.append("")
+ lines.append("| Modifiers | Fields |")
+ lines.append("|---|---|")
+
+ both = []
+ paid_only = []
+ aspire_only = []
+ neither = []
+
+ for code in sorted(fields.keys()):
+ f = fields[code]
+ mods = f.get("modifiers", {})
+ has_paid = mods.get("paid", False)
+ has_aspire = mods.get("aspire", False)
+ if has_paid and has_aspire:
+ both.append(code)
+ elif has_paid:
+ paid_only.append(code)
+ elif has_aspire:
+ aspire_only.append(code)
+ else:
+ neither.append(code)
+
+ if both:
+ lines.append(
+ f"| `$` and `+` | {' '.join(f'`{c}`' for c in both)} |"
+ )
+ if paid_only:
+ lines.append(
+ f"| `$` only | {' '.join(f'`{c}`' for c in paid_only)} |"
+ )
+ if aspire_only:
+ lines.append(
+ f"| `+` only | {' '.join(f'`{c}`' for c in aspire_only)} |"
+ )
+ if neither:
+ lines.append(
+ f"| Neither | {' '.join(f'`{c}`' for c in neither)} |"
+ )
+
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_field_list(fields, fmt):
+ lines = []
+ lines.append("## Field list")
+ lines.append("")
+ lines.append("| Code | Field | Type | Category |")
+ lines.append("|---|---|---|---|")
+
+ reserved = fmt.get("reserved_codes", [])
+ all_codes = sorted(set(list(fields.keys()) + reserved))
+
+ for code in all_codes:
+ if code in reserved:
+ lines.append(f"| `{code}` | *(reserved)* | — | — |")
+ continue
+ f = fields[code]
+ ftype = f["type"]
+ mods = f.get("modifiers", {})
+ has_alt = mods.get("alternative", False)
+
+ type_str = ftype.capitalize()
+ if ftype == "direct" and has_alt:
+ type_str = "Direct, `/` ok"
+ elif ftype == "special":
+ if code == "b":
+ type_str = "Special (h/w)"
+ elif code == "v":
+ type_str = "Special (s/e)"
+ elif code == "g":
+ type_str = "Special"
+ elif code == "l":
+ type_str = "Multi (ISO)"
+ elif ftype == "multi":
+ if code == "h":
+ type_str = "Multi (1-letter)"
+ elif code == "l":
+ type_str = "Multi (ISO)"
+ elif ftype == "single":
+ type_str = "Single"
+
+ lines.append(
+ f"| `{code}` | {f['name']} | {type_str} | {f['category'].title()} |"
+ )
+
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_field_def_direct(code, f):
+ """Generate a direct-type field definition."""
+ lines = []
+ vals = f.get("values", {})
+ if vals:
+ for i in range(8):
+ v = vals.get(str(i), {})
+ label = v.get("label", "—")
+ humor = v.get("humor")
+ if humor:
+ lines.append(f"{i}. **{label}** — {humor}")
+ else:
+ lines.append(f"{i}. **{label}**")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_field_def_special_b(f):
+ """Generate build field definition."""
+ lines = []
+ lines.append(
+ "Format: `b<height>/<width>`. Each 0–7. "
+ "0 = extremely small, 4 = average, 7 = extremely large. "
+ "`+` modifier permitted."
+ )
+ lines.append("")
+ for dim_name, dim in f["dimensions"].items():
+ lines.append(f"**{dim_name.title()}:** ", )
+ vals = dim["values"]
+ parts = [f"{k} = {v}" for k, v in sorted(vals.items(), key=lambda x: int(x[0]))]
+ lines[-1] += " · ".join(parts)
+ lines.append("")
+ lines.append("> `b6/3` — tall and slim.")
+ lines.append("> `b4/4` — average. Chairs were designed for you.")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_field_def_special_v(f):
+ """Generate politics field definition."""
+ lines = []
+ lines.append("Format: `v<social>/<economic>`. No modifiers.")
+ lines.append("")
+ for dim_name, dim in f["dimensions"].items():
+ vals = dim["values"]
+ low = vals.get("0", "")
+ mid = vals.get("4", "")
+ high = vals.get("7", "")
+ lines.append(f"**{dim_name.title()}:** 0 = {low} · 4 = {mid} · 7 = {high}")
+ lines.append("")
+ lines.append("> `v5/3` — socially center-left, economically center-right.")
+ lines.append("> `v4/4` — centrist. You annoy both sides equally.")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_field_def_special_g(f):
+ """Generate geek type field definition."""
+ lines = []
+ lines.append(
+ "Mandatory. `/` separates domains. `~` prefixes custom domains. "
+ "`$` after a domain = paid. No ratings."
+ )
+ lines.append("")
+
+ groups = f.get("sub_id_groups", {})
+ for group_key, group in groups.items():
+ label = group.get("label", group_key)
+ sub_ids = group.get("sub_ids", {})
+
+ # For larger groups, use a table
+ items = list(sub_ids.items())
+ if len(items) > 7:
+ lines.append(f"#### {label}")
+ lines.append("")
+ lines.append("| Code | Domain | Code | Domain |")
+ lines.append("|---|---|---|---|")
+ # Pair them up
+ for i in range(0, len(items), 2):
+ c1, n1 = items[i]
+ if isinstance(n1, dict):
+ n1 = n1.get("name", n1.get("long", c1))
+ row = f"| `{c1}` | {n1}"
+ if i + 1 < len(items):
+ c2, n2 = items[i + 1]
+ if isinstance(n2, dict):
+ n2 = n2.get("name", n2.get("long", c2))
+ row += f" | `{c2}` | {n2} |"
+ else:
+ row += " | | |"
+ lines.append(row)
+ lines.append("")
+ else:
+ # Inline format for smaller groups
+ lines.append(f"#### {label}")
+ lines.append("")
+ parts = []
+ for c, n in items:
+ if isinstance(n, dict):
+ n = n.get("name", n.get("long", c))
+ parts.append(f"`{c}` {n}")
+ lines.append(" · ".join(parts))
+ lines.append("")
+
+ lines.append("#### Custom")
+ lines.append("")
+ lines.append(
+ "`~fermenting` `~beekeeping` `~lockpicking` `~origami` — anything goes."
+ )
+ lines.append("")
+ lines.append("> `gcs$/ai/wr~fermenting` — paid CS geek, also into AI, writing, and fermenting.")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_sub_id_table(sub_ids):
+ """Generate a sub-ID table from a flat sub_ids dict."""
+ lines = []
+ items = list(sub_ids.items())
+
+ # Check if it's a two-column table or three-column
+ has_long = any(
+ isinstance(v, dict) and "long" in v for v in sub_ids.values()
+ )
+
+ if has_long:
+ # Pair them for a wider table
+ lines.append("| Short | (Long) | Name | Short | (Long) | Name |")
+ lines.append("|---|---|---|---|---|---|")
+ for i in range(0, len(items), 2):
+ c1, v1 = items[i]
+ if isinstance(v1, dict):
+ long1 = f"`({v1['long']})`" if "long" in v1 else "—"
+ name1 = v1.get("name", c1)
+ else:
+ long1 = "—"
+ name1 = v1
+ row = f"| `{c1}` | {long1} | {name1}"
+
+ if i + 1 < len(items):
+ c2, v2 = items[i + 1]
+ if isinstance(v2, dict):
+ long2 = f"`({v2['long']})`" if "long" in v2 else "—"
+ name2 = v2.get("name", c2)
+ else:
+ long2 = "—"
+ name2 = v2
+ row += f" | `{c2}` | {long2} | {name2} |"
+ else:
+ row += " | | | |"
+ lines.append(row)
+ else:
+ lines.append("| Short | Name |")
+ lines.append("|---|---|")
+ for c, v in items:
+ if isinstance(v, dict):
+ name = v.get("name", c)
+ else:
+ name = v
+ lines.append(f"| `{c}` | {name} |")
+
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_grouped_sub_ids(groups, inline_threshold=7):
+ """Generate sub-ID tables from grouped sub_ids."""
+ lines = []
+ for group_key, group in groups.items():
+ label = group.get("label", group_key)
+ sub_ids = group.get("sub_ids", {})
+ items = list(sub_ids.items())
+
+ # Check for long forms
+ has_long = any(
+ isinstance(v, dict) and "long" in v for v in sub_ids.values()
+ )
+
+ if len(items) <= inline_threshold and not has_long:
+ # Inline format
+ parts = []
+ for c, v in items:
+ if isinstance(v, dict):
+ long_str = f"({v['long']}) " if "long" in v else ""
+ name = v.get("name", c)
+ else:
+ long_str = ""
+ name = v
+ parts.append(f"`{c}` {long_str}{name}")
+ lines.append(f"**{label}:**")
+ lines.append(" · ".join(parts))
+ lines.append("")
+ else:
+ # Table format with long forms
+ lines.append(f"**{label}:**")
+ lines.append("")
+ if has_long:
+ lines.append("| Short | (Long) | Name | Short | (Long) | Name |")
+ lines.append("|---|---|---|---|---|---|")
+ for i in range(0, len(items), 2):
+ c1, v1 = items[i]
+ if isinstance(v1, dict):
+ long1 = f"`({v1['long']})`" if "long" in v1 else "—"
+ name1 = v1.get("name", c1)
+ else:
+ long1 = "—"
+ name1 = v1
+ row = f"| `{c1}` | {long1} | {name1}"
+ if i + 1 < len(items):
+ c2, v2 = items[i + 1]
+ if isinstance(v2, dict):
+ long2 = f"`({v2['long']})`" if "long" in v2 else "—"
+ name2 = v2.get("name", c2)
+ else:
+ long2 = "—"
+ name2 = v2
+ row += f" | `{c2}` | {long2} | {name2} |"
+ else:
+ row += " | | | |"
+ lines.append(row)
+ else:
+ lines.append("| Short | Name |")
+ lines.append("|---|---|")
+ for c, v in items:
+ if isinstance(v, dict):
+ name = v.get("name", c)
+ else:
+ name = v
+ lines.append(f"| `{c}` | {name} |")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def gen_field_def_multi(code, f, scale):
+ """Generate a multi-type field definition."""
+ lines = []
+
+ # Sub-IDs
+ if "sub_id_groups" in f:
+ lines.append(gen_grouped_sub_ids(f["sub_id_groups"]))
+ elif "sub_ids" in f:
+ lines.append(gen_sub_id_table(f["sub_ids"]))
+
+ # Scale info
+ scale_type = f.get("scale_type")
+ if scale_type and scale_type != "custom":
+ lines.append(f"Scale: **{scale_type}** (see global scale table).")
+ lines.append("")
+
+ # Custom scale labels
+ if "scale_labels" in f:
+ humor = f.get("humor_scale", {}) or {}
+ for i in range(8):
+ label = f["scale_labels"].get(str(i), "—")
+ h = humor.get(str(i))
+ if h:
+ lines.append(f"{i}. **{label}** — {h}")
+ else:
+ lines.append(f"{i}. **{label}**")
+ lines.append("")
+ elif f.get("humor_scale"):
+ humor = f["humor_scale"]
+ for val in ["0", "7"]:
+ if val in humor:
+ lines.append(f"> {val}: {humor[val]}")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def gen_field_def_single(code, f):
+ """Generate a single-type field definition."""
+ lines = []
+ lines.append("**Single value only** — one tradition + devoutness rating. Parser rejects multiple sub-IDs.")
+ lines.append("")
+
+ if "sub_ids" in f:
+ lines.append(gen_sub_id_table(f["sub_ids"]))
+
+ if "scale_labels" in f:
+ sl = f["scale_labels"]
+ lines.append(f"Rating = devoutness: 0 = {sl.get('0', '')}, 4 = {sl.get('4', '')}, 7 = {sl.get('7', '')}.")
+ lines.append("")
+
+ # Example
+ if code == "r":
+ lines.append("> `ris6` — devout Muslim. `rath4` — culturally atheist.")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def gen_field_def_lang(f):
+ """Generate spoken languages field definition."""
+ lines = []
+ lines.append("ISO 639-1 codes + proficiency 0–7. `$` and `+` permitted.")
+ lines.append("")
+
+ if "scale_labels" in f:
+ humor = f.get("humor_scale", {}) or {}
+ for i in range(8):
+ label = f["scale_labels"].get(str(i), "—")
+ h = humor.get(str(i))
+ if h:
+ lines.append(f"{i}. **{label}** — {h}")
+ else:
+ lines.append(f"{i}. **{label}**")
+ lines.append("")
+
+ # Common codes
+ codes = f.get("common_codes", {})
+ if codes:
+ items = list(codes.items())
+ # Show first ~25 inline
+ parts = [f"`{c}` {n}" for c, n in items[:26]]
+ lines.append("Common codes: " + " · ".join(parts))
+ lines.append("")
+
+ lines.append(
+ "> `lar7$en6de3+5` — native Arabic (paid translator), fluent English, "
+ "learning German (beginner, aspiring proficient)."
+ )
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_field_definitions(fields, scale):
+ lines = []
+ lines.append("## Field definitions")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+
+ for code in sorted(fields.keys()):
+ f = fields[code]
+ lines.append(f"### {code} — {f['name']}")
+ lines.append("")
+
+ ftype = f["type"]
+
+ if ftype == "direct":
+ lines.append(gen_field_def_direct(code, f))
+ elif ftype == "special":
+ if code == "g":
+ lines.append(gen_field_def_special_g(f))
+ elif code == "b":
+ lines.append(gen_field_def_special_b(f))
+ elif code == "v":
+ lines.append(gen_field_def_special_v(f))
+ elif code == "l":
+ lines.append(gen_field_def_lang(f))
+ elif ftype == "multi":
+ # Special intro for certain fields
+ if code == "d":
+ lines.append("The editor holy war, now quantified.")
+ lines.append("")
+ if code == "l":
+ # l is type "multi" in JSON but uses ISO 639-1 codes
+ lines.append(gen_field_def_lang(f))
+ else:
+ lines.append(gen_field_def_multi(code, f, scale))
+ elif ftype == "single":
+ lines.append(gen_field_def_single(code, f))
+
+ # Note modifiers
+ mods = f.get("modifiers", {})
+ mod_parts = []
+ if mods.get("paid"):
+ mod_parts.append("`$` paid")
+ if mods.get("aspire"):
+ mod_parts.append("`+` aspire")
+ if mods.get("alternative"):
+ mod_parts.append("`/` alternative")
+ # Only show if there are interesting modifiers to mention
+ # (skip if already covered in special format text)
+ if mod_parts and ftype not in ("special",):
+ pass # Modifiers are covered in the permissions table
+
+ lines.append("---")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def gen_examples(examples, fields):
+ lines = []
+ lines.append("## Examples")
+ lines.append("")
+
+ for ex in examples:
+ handle = ex["handle"]
+ desc = ex["description"]
+ uri = ex["uri"]
+
+ lines.append(f"### {handle} — {desc}")
+ lines.append("")
+
+ # Generate block format from URI
+ block = uri_to_block(uri, fields)
+ lines.append("```")
+ for bl in block:
+ lines.append(bl)
+ lines.append("```")
+ lines.append("")
+ lines.append("```")
+ lines.append(uri)
+ lines.append("```")
+ lines.append("")
+
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def uri_to_block(uri, fields):
+ """Convert a URI string to block format lines."""
+ # Parse: ugi:<version>@<handle>:<fields>
+ # Just use the existing block examples from the hand-written spec
+ # since exact conversion logic is complex. For now, output the URI.
+ # Actually, let's parse it properly.
+
+ rest = uri
+ if rest.startswith("ugi:"):
+ rest = rest[4:]
+
+ # Split version@handle:fields
+ at_idx = rest.index("@")
+ version = rest[:at_idx]
+ rest = rest[at_idx + 1:]
+
+ colon_idx = rest.index(":")
+ handle = rest[:colon_idx]
+ field_str = rest[colon_idx + 1:]
+
+ field_parts = field_str.split(",")
+
+ block_lines = []
+ block_lines.append("------- BEGIN UGI BLOCK -------")
+
+ # First line: version, handle, and g field
+ first_line = f"v:{version} @{handle}"
+ remaining = []
+
+ for part in field_parts:
+ if not part:
+ continue
+ code = part[0].lower()
+ if code == "g":
+ # Geek type goes on first line
+ first_line += f" G{part[1:]}"
+ else:
+ remaining.append(part)
+
+ block_lines.append(first_line)
+
+ # Group remaining fields into lines by category
+ identity = []
+ tech = []
+ stance = []
+ entertainment = []
+ lifestyle = []
+
+ for part in remaining:
+ code = part[0].lower()
+ f = fields.get(code)
+ if not f:
+ tech.append(part)
+ continue
+ cat = f.get("category", "")
+ # Expand multi fields
+ expanded = expand_field_block(part, f)
+ if cat == "identity":
+ identity.extend(expanded)
+ elif cat in ("tech",):
+ tech.extend(expanded)
+ elif cat == "stance":
+ stance.extend(expanded)
+ elif cat == "entertainment":
+ entertainment.extend(expanded)
+ elif cat in ("lifestyle", "appearance"):
+ lifestyle.extend(expanded)
+ else:
+ tech.extend(expanded)
+
+ # Output lines
+ if identity:
+ block_lines.append(" ".join(identity))
+ if tech:
+ block_lines.append(" ".join(tech))
+ if stance:
+ block_lines.append(" ".join(stance))
+ if entertainment:
+ block_lines.append(" ".join(entertainment))
+ if lifestyle:
+ block_lines.append(" ".join(lifestyle))
+
+ block_lines.append("-------- END UGI BLOCK --------")
+ return block_lines
+
+
+def expand_field_block(part, f):
+ """Expand a URI field part into block format parts."""
+ code = part[0]
+ rest = part[1:]
+ ftype = f["type"]
+ uc = code.upper()
+
+ if ftype == "direct":
+ return [f"{uc}{rest}"]
+ elif ftype == "special":
+ if code == "b" or code == "v":
+ return [f"{uc}{rest}"]
+ elif code == "g":
+ return [f"G{rest}"]
+ elif code == "l":
+ # Parse: 2-char code + digit + mods, repeating
+ expanded = []
+ i = 0
+ while i < len(rest):
+ if rest[i] == "~":
+ # custom
+ j = i + 1
+ while j < len(rest) and rest[j].isalpha():
+ j += 1
+ sub = rest[i:j]
+ digit = rest[j] if j < len(rest) and rest[j].isdigit() else ""
+ mod = ""
+ k = j + 1
+ while k < len(rest) and rest[k] in "$+0123456789":
+ mod += rest[k]
+ k += 1
+ expanded.append(f"L{sub}{digit}{mod}")
+ i = k
+ elif rest[i].isalpha():
+ sub = rest[i:i + 2]
+ digit = rest[i + 2] if i + 2 < len(rest) else ""
+ mod = ""
+ k = i + 3
+ while k < len(rest) and rest[k] in "$+0123456789":
+ mod += rest[k]
+ k += 1
+ expanded.append(f"L{sub}{digit}{mod}")
+ i = k
+ else:
+ i += 1
+ return expanded
+ return [f"{uc}{rest}"]
+ elif ftype in ("multi", "single"):
+ # Parse sub-id + digit + mods
+ expanded = []
+ i = 0
+ while i < len(rest):
+ if rest[i] == "~":
+ j = i + 1
+ while j < len(rest) and rest[j].isalpha():
+ j += 1
+ sub = rest[i:j]
+ digit = rest[j] if j < len(rest) and rest[j].isdigit() else ""
+ mod = ""
+ k = j + 1
+ while k < len(rest) and rest[k] in "$+0123456789":
+ mod += rest[k]
+ k += 1
+ expanded.append(f"{uc}{sub}{digit}{mod}")
+ i = k
+ elif rest[i].isalpha():
+ # Read sub-id (1-4 alpha chars)
+ j = i
+ while j < len(rest) and rest[j].isalpha():
+ j += 1
+ sub = rest[i:j]
+ digit = rest[j] if j < len(rest) and rest[j].isdigit() else ""
+ mod = ""
+ k = j + 1
+ while k < len(rest) and rest[k] in "$+0123456789":
+ mod += rest[k]
+ k += 1
+ expanded.append(f"{uc}{sub}{digit}{mod}")
+ i = k
+ else:
+ i += 1
+ return expanded
+ return [f"{uc}{rest}"]
+
+
+def gen_extending():
+ lines = []
+ lines.append("## Extending UGI")
+ lines.append("")
+ lines.append("Any field with sub-IDs accepts custom entries via `~`:")
+ lines.append("")
+ lines.append("```")
+ lines.append("gcs/~mycology geek of CS and mycology")
+ lines.append("m~synthwave6 synthwave enthusiast")
+ lines.append("t~severance7 severance obsessed")
+ lines.append("j~larp5 LARP, competent")
+ lines.append("o~beos4 BeOS, neutral nostalgia")
+ lines.append("```")
+ lines.append("")
+ lines.append("Letters `n` and `u` are reserved for future spec versions.")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_comparison(comparison):
+ lines = []
+ lines.append("## Comparison with predecessors")
+ lines.append("")
+ lines.append(
+ "| | Geek Code 3.12 (1996) | Geek Code 2026 | Hacker Key v4 (2006) | UGI v0 |"
+ )
+ lines.append("|---|---|---|---|---|")
+ for row in comparison["features"]:
+ lines.append("| " + " | ".join(row) + " |")
+ lines.append("")
+
+ # Retained
+ lines.append("### Fields retained")
+ lines.append("")
+ parts = []
+ for r in comparison["retained"]:
+ parts.append(f"`{r['ugi']}` {r['note'].lower()}")
+ lines.append(
+ ", ".join(parts)
+ + " — all present in one or both predecessors."
+ )
+ lines.append("")
+
+ # Added
+ lines.append("### Fields added")
+ lines.append("")
+ for a in comparison["added"]:
+ lines.append(f"`{a['ugi']}` — {a['reason']}. ", )
+ lines.append("")
+
+ # Removed
+ lines.append("### Fields removed")
+ lines.append("")
+ parts = []
+ for r in comparison["removed"]:
+ parts.append(f"{r['field']} — {r['reason'].lower()}")
+ lines.append(". ".join(parts) + ".")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_quick_ref(fields):
+ lines = []
+ lines.append("## Quick reference")
+ lines.append("")
+ lines.append("```")
+ lines.append("FORMAT: ugi:<ver>[/<rev>]@<handle>:<fields>")
+ lines.append(
+ "SCALE: 0=hostile 1=dislike 2=meh 3=slight- 4=neutral 5=like 6=strong 7=obsessed"
+ )
+ lines.append(
+ "MODIFY: $ = paid + = aspire / = fluctuate,separate ~ = custom"
+ )
+ lines.append("")
+
+ # Field codes
+ codes = sorted(fields.keys())
+ all_codes = []
+ for c in "abcdefghijklmnopqrstuvwxyz":
+ if c in fields:
+ f = fields[c]
+ name = f["name"].lower()
+ all_codes.append(f"{c} {name}")
+ elif c in ("n", "u"):
+ all_codes.append(f"[{c} reserved]")
+
+ # Print in rows of 5
+ for i in range(0, len(all_codes), 5):
+ row = all_codes[i : i + 5]
+ lines.append(" ".join(f"{x:<14}" for x in row).rstrip())
+ lines.append("")
+
+ # Modifier groups
+ both = []
+ paid_only = []
+ aspire_only = []
+ neither = []
+ for code in sorted(fields.keys()):
+ f = fields[code]
+ mods = f.get("modifiers", {})
+ has_paid = mods.get("paid", False)
+ has_aspire = mods.get("aspire", False)
+ if has_paid and has_aspire:
+ both.append(code)
+ elif has_paid:
+ paid_only.append(code)
+ elif has_aspire:
+ aspire_only.append(code)
+ else:
+ neither.append(code)
+
+ lines.append(f"$+ {' '.join(both)} $ only {' '.join(paid_only)} + only {' '.join(aspire_only)} none {' '.join(neither)}")
+ lines.append("```")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_references(spec):
+ lines = []
+ lines.append("## References")
+ lines.append("")
+ for p in spec["predecessors"]:
+ lines.append(f"- [{p['name']} (v0.1–latest)]({p['url']}) — {p['author']}, {p['years']}")
+ if "continuation" in p:
+ c = p["continuation"]
+ lines.append(f"- [{c['name']}]({c['url']}) — {c['author']}, {c['years']}")
+ for r in spec.get("references", []):
+ lines.append(f"- [{r['title']}]({r['url']})")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ lines.append("## License")
+ lines.append("")
+ lines.append(f"{spec['license']} — see [LICENSE](./LICENSE).")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(f"Usage: {sys.argv[0]} <registry.json> <output.md>", file=sys.stderr)
+ sys.exit(1)
+
+ registry_path, output_path = sys.argv[1], sys.argv[2]
+ reg = load_registry(registry_path)
+ spec = reg["spec"]
+ fmt = reg["format"]
+ scale = reg["scale"]
+ fields = reg["fields"]
+ modifiers = reg["modifiers"]
+ examples = reg["examples"]
+ comparison = reg["comparison"]
+
+ parts = [
+ gen_title(spec),
+ gen_overview(spec),
+ gen_formats(fmt),
+ gen_grammar(fields),
+ gen_scale(scale),
+ gen_modifier_permissions(fields, modifiers),
+ gen_field_list(fields, fmt),
+ gen_field_definitions(fields, scale),
+ gen_extending(),
+ gen_examples(examples, fields),
+ gen_comparison(comparison),
+ gen_quick_ref(fields),
+ gen_references(spec),
+ ]
+
+ # Each part ends with "\n" from join; ensure blank line between sections
+ output = "\n".join(p.rstrip("\n") for p in parts) + "\n"
+
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
+ with open(output_path, "w") as f:
+ f.write(output)
+
+ print(f"Generated {output_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/spec/tests/cases.json b/spec/tests/cases.json
new file mode 100644
index 0000000..d27976f
--- /dev/null
+++ b/spec/tests/cases.json
@@ -0,0 +1,780 @@
+{
+ "_doc": "UGI test cases. Each case has a URI, its equivalent block, and the expected decoded fields. All derived from ugi_registry_v0.json.",
+
+ "cases": [
+
+ {
+ "id": "minimal",
+ "description": "Minimum valid UGI: handle + mandatory geek field, single domain",
+ "uri": "ugi:0@min:gcs",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @min Gcs\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "min",
+ "fields": {
+ "g": { "type": "special", "domains": [{"id": "cs", "name": "Computer Science", "paid": false}] }
+ }
+ }
+ },
+
+ {
+ "id": "minimal_paid_geek",
+ "description": "Single paid geek domain",
+ "uri": "ugi:0@dev:gcs$",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @dev Gcs$\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "dev",
+ "fields": {
+ "g": { "type": "special", "domains": [{"id": "cs", "name": "Computer Science", "paid": true}] }
+ }
+ }
+ },
+
+ {
+ "id": "geek_multi_domain",
+ "description": "Multiple geek domains, one paid",
+ "uri": "ugi:0@alice:gcs$/ai/wr",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @alice Gcs$/ai/wr\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "alice",
+ "fields": {
+ "g": { "type": "special", "domains": [
+ {"id": "cs", "name": "Computer Science", "paid": true},
+ {"id": "ai", "name": "Artificial Intelligence", "paid": false},
+ {"id": "wr", "name": "Writing", "paid": false}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "geek_custom_domain",
+ "description": "Geek field with custom ~domain",
+ "uri": "ugi:0@bob:gcs/~fermenting",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @bob Gcs/~fermenting\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "bob",
+ "fields": {
+ "g": { "type": "special", "domains": [
+ {"id": "cs", "name": "Computer Science", "paid": false},
+ {"id": "~fermenting", "name": "~fermenting", "paid": false}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "geek_paid_and_custom",
+ "description": "Geek: paid domain + custom domain",
+ "uri": "ugi:0@chef:gfd$/~sourdough",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @chef Gfd$/~sourdough\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "chef",
+ "fields": {
+ "g": { "type": "special", "domains": [
+ {"id": "fd", "name": "Food Science", "paid": true},
+ {"id": "~sourdough", "name": "~sourdough", "paid": false}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "version_with_revision",
+ "description": "Version with revision number",
+ "uri": "ugi:0/3@rev:gcs",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0/3 @rev Gcs\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0/3",
+ "handle": "rev",
+ "fields": {
+ "g": { "type": "special", "domains": [{"id": "cs", "name": "Computer Science", "paid": false}] }
+ }
+ }
+ },
+
+ {
+ "id": "direct_no_modifiers",
+ "description": "Direct field with no modifiers (age: no $, no +, no /)",
+ "uri": "ugi:0@t1:gcs,a4",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t1 Gcs\nA4\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t1",
+ "fields": {
+ "g": { "type": "special", "domains": [{"id": "cs", "name": "Computer Science", "paid": false}] },
+ "a": { "type": "direct", "value": 4, "label": "25-34" }
+ }
+ }
+ },
+
+ {
+ "id": "direct_all_values",
+ "description": "Direct field at each scale extreme: age 0 and age 7",
+ "uri_0": "ugi:0@kid:gcs,a0",
+ "uri_7": "ugi:0@elder:gcs,a7",
+ "decoded_0": { "a": { "value": 0, "label": "Under 10" } },
+ "decoded_7": { "a": { "value": 7, "label": "65+" } }
+ },
+
+ {
+ "id": "direct_alternative",
+ "description": "Direct field with / alternative (clothing: c3/6)",
+ "uri": "ugi:0@t2:gcs,c3/6",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t2 Gcs\nC3/6\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t2",
+ "fields": {
+ "c": { "type": "direct", "value": 3, "label": "Jeans & tee", "alt_value": 6, "alt_label": "Formal" }
+ }
+ }
+ },
+
+ {
+ "id": "direct_paid",
+ "description": "Direct field with $ paid modifier (food: f5$)",
+ "uri": "ugi:0@t3:gcs,f5$",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t3 Gcs\nF5$\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t3",
+ "fields": {
+ "f": { "type": "direct", "value": 5, "label": "Experiments", "paid": true }
+ }
+ }
+ },
+
+ {
+ "id": "direct_aspire",
+ "description": "Direct field with +N aspire modifier (education: e4+6)",
+ "uri": "ugi:0@t4:gcs,e4+6",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t4 Gcs\nE4+6\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t4",
+ "fields": {
+ "e": { "type": "direct", "value": 4, "label": "Bachelors", "aspire": 6 }
+ }
+ }
+ },
+
+ {
+ "id": "direct_paid_aspire",
+ "description": "Direct field with both $ and +N (hardware: w5$+7)",
+ "uri": "ugi:0@t5:gcs,w5$+7",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t5 Gcs\nW5$+7\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t5",
+ "fields": {
+ "w": { "type": "direct", "value": 5, "label": "Comfortable", "paid": true, "aspire": 7 }
+ }
+ }
+ },
+
+ {
+ "id": "direct_alt_and_aspire",
+ "description": "Direct field with / alternative and +N aspire (security: q3/6+7)",
+ "uri": "ugi:0@t6:gcs,q3/6+7",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t6 Gcs\nQ3/6+7\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t6",
+ "fields": {
+ "q": { "type": "direct", "value": 3, "label": "Basic awareness", "alt_value": 6, "alt_label": "Encrypts everything", "aspire": 7 }
+ }
+ }
+ },
+
+ {
+ "id": "direct_all_three_modifiers",
+ "description": "Direct field with $, /, and + (security: q5$/6+7 — paid, fluctuates, aspires)",
+ "uri": "ugi:0@t6b:gcs,q5$/6+7",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t6b Gcs\nQ5$/6+7\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t6b",
+ "fields": {
+ "q": { "type": "direct", "value": 5, "label": "2FA everywhere", "paid": true, "alt_value": 6, "alt_label": "Encrypts everything", "aspire": 7 }
+ }
+ }
+ },
+
+ {
+ "id": "special_build",
+ "description": "Build field: height/width",
+ "uri": "ugi:0@t7:gcs,b6/3",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t7 Gcs\nB6/3\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t7",
+ "fields": {
+ "b": { "type": "special", "dimensions": {"height": {"value": 6, "label": "Tall"}, "width": {"value": 3, "label": "Below average"}} }
+ }
+ }
+ },
+
+ {
+ "id": "special_build_extremes",
+ "description": "Build field at extremes: b0/0 and b7/7",
+ "uri_min": "ugi:0@tiny:gcs,b0/0",
+ "uri_max": "ugi:0@huge:gcs,b7/7",
+ "decoded_min": { "b": { "dimensions": {"height": {"value": 0, "label": "Extremely short"}, "width": {"value": 0, "label": "Extremely thin"}} } },
+ "decoded_max": { "b": { "dimensions": {"height": {"value": 7, "label": "Extremely tall"}, "width": {"value": 7, "label": "Extremely broad"}} } }
+ },
+
+ {
+ "id": "special_politics",
+ "description": "Politics field: social/economic",
+ "uri": "ugi:0@t8:gcs,v5/3",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t8 Gcs\nV5/3\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t8",
+ "fields": {
+ "v": { "type": "special", "dimensions": {"social": {"value": 5, "label": "Center-left"}, "economic": {"value": 3, "label": "Center-right"}} }
+ }
+ }
+ },
+
+ {
+ "id": "special_politics_extremes",
+ "description": "Politics at extremes: v0/0 and v7/7",
+ "uri_min": "ugi:0@auth:gcs,v0/0",
+ "uri_max": "ugi:0@lib:gcs,v7/7",
+ "decoded_min": { "v": { "dimensions": {"social": {"value": 0, "label": "Far-right authoritarian"}, "economic": {"value": 0, "label": "Unregulated capitalism"}} } },
+ "decoded_max": { "v": { "dimensions": {"social": {"value": 7, "label": "Far-left libertarian"}, "economic": {"value": 7, "label": "Full socialism"}} } }
+ },
+
+ {
+ "id": "multi_single_subid",
+ "description": "Multi field with one sub-ID (programming: ppy6)",
+ "uri": "ugi:0@t9:gcs,ppy6",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t9 Gcs\nPpy6\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t9",
+ "fields": {
+ "p": { "type": "multi", "entries": [
+ {"sub_id": "py", "name": "Python", "value": 6, "label": "Advanced / daily"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "multi_merged_subids",
+ "description": "Multi field with multiple sub-IDs merged in URI, expanded in block",
+ "uri": "ugi:0@t10:gcs,ppy6rs5js4",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t10 Gcs\nPpy6 Prs5 Pjs4\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t10",
+ "fields": {
+ "p": { "type": "multi", "entries": [
+ {"sub_id": "py", "name": "Python", "value": 6, "label": "Advanced / daily"},
+ {"sub_id": "rs", "name": "Rust", "value": 5, "label": "Competent"},
+ {"sub_id": "js", "name": "JavaScript", "value": 4, "label": "Average"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "multi_paid_subid",
+ "description": "Multi field with $ on a sub-ID (programming: ppy6$)",
+ "uri": "ugi:0@t11:gcs,ppy6$",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t11 Gcs\nPpy6$\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t11",
+ "fields": {
+ "p": { "type": "multi", "entries": [
+ {"sub_id": "py", "name": "Python", "value": 6, "label": "Advanced / daily", "paid": true}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "multi_aspire_subid",
+ "description": "Multi field with +N on a sub-ID (programming: prs4+6)",
+ "uri": "ugi:0@t12:gcs,prs4+6",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t12 Gcs\nPrs4+6\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t12",
+ "fields": {
+ "p": { "type": "multi", "entries": [
+ {"sub_id": "rs", "name": "Rust", "value": 4, "label": "Average", "aspire": 6}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "multi_paid_aspire_subid",
+ "description": "Multi field with $+N combined on a sub-ID (programming: pjs5$+7)",
+ "uri": "ugi:0@t13:gcs,pjs5$+7",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t13 Gcs\nPjs5$+7\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t13",
+ "fields": {
+ "p": { "type": "multi", "entries": [
+ {"sub_id": "js", "name": "JavaScript", "value": 5, "label": "Competent", "paid": true, "aspire": 7}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "multi_mixed_modifiers",
+ "description": "Multi field with different modifiers on different sub-IDs",
+ "uri": "ugi:0@t14:gcs,ppy7$rs5+7js6$+7go3",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t14 Gcs\nPpy7$ Prs5+7 Pjs6$+7 Pgo3\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t14",
+ "fields": {
+ "p": { "type": "multi", "entries": [
+ {"sub_id": "py", "name": "Python", "value": 7, "label": "Master / creator", "paid": true},
+ {"sub_id": "rs", "name": "Rust", "value": 5, "label": "Competent", "aspire": 7},
+ {"sub_id": "js", "name": "JavaScript", "value": 6, "label": "Advanced / daily", "paid": true, "aspire": 7},
+ {"sub_id": "go", "name": "Go", "value": 3, "label": "Beginner"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "multi_custom_subid",
+ "description": "Multi field with custom ~sub-ID (music: m~synthwave6)",
+ "uri": "ugi:0@t15:gcs,m~synthwave6",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t15 Gcs\nM~synthwave6\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t15",
+ "fields": {
+ "m": { "type": "multi", "entries": [
+ {"sub_id": "~synthwave", "name": "~synthwave", "value": 6, "label": "Enthusiast"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "multi_mixed_and_custom",
+ "description": "Multi field with known + custom sub-IDs (music: mrk6~synthwave7el5)",
+ "uri": "ugi:0@t16:gcs,mrk6~synthwave7el5",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t16 Gcs\nMrk6 M~synthwave7 Mel5\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t16",
+ "fields": {
+ "m": { "type": "multi", "entries": [
+ {"sub_id": "rk", "name": "Rock", "value": 6, "label": "Enthusiast"},
+ {"sub_id": "~synthwave", "name": "~synthwave", "value": 7, "label": "Life-defining"},
+ {"sub_id": "el", "name": "Electronic", "value": 5, "label": "Enjoy"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "multi_grouped_subids",
+ "description": "Multi field with sub-IDs from sub_id_groups (gaming: jrp7pc6dn5)",
+ "uri": "ugi:0@t17:gcs,jrp7pc6dn5",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t17 Gcs\nJrp7 Jpc6 Jdn5\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t17",
+ "fields": {
+ "j": { "type": "multi", "entries": [
+ {"sub_id": "rp", "name": "RPG", "value": 7, "label": "Life-defining"},
+ {"sub_id": "pc", "name": "PC", "value": 6, "label": "Enthusiast"},
+ {"sub_id": "dn", "name": "D&D", "value": 5, "label": "Enjoy"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "multi_1char_subid",
+ "description": "Multi field with 1-char sub-IDs (hair: hh4f6) and single-char sub-IDs (programming: pc5 — 'c' is 1-char)",
+ "uri": "ugi:0@t18:gcs,hh4f6,pc5",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t18 Gcs\nHh4 Hf6\nPc5\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t18",
+ "fields": {
+ "h": { "type": "multi", "entries": [
+ {"sub_id": "h", "name": "Head", "value": 4, "label": "Average"},
+ {"sub_id": "f", "name": "Face / beard", "value": 6, "label": "Impressive"}
+ ]},
+ "p": { "type": "multi", "entries": [
+ {"sub_id": "c", "name": "C", "value": 5, "label": "Competent"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "multi_3char_subid",
+ "description": "Multi field with 3-char sub-ID from sub_id_groups (OS: oxsl5 — 'xsl' is Slackware)",
+ "uri": "ugi:0@t19:gcs,oxsl5",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t19 Gcs\nOxsl5\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t19",
+ "fields": {
+ "o": { "type": "multi", "entries": [
+ {"sub_id": "xsl", "name": "Slackware", "value": 5, "label": "Competent"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "single_type",
+ "description": "Single-type field: exactly one sub-ID + digit (religion: ris6)",
+ "uri": "ugi:0@t20:gcs,ris6",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t20 Gcs\nRis6\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t20",
+ "fields": {
+ "r": { "type": "single", "entries": [
+ {"sub_id": "is", "name": "Islam", "value": 6, "label": "Devout"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "single_type_atheist",
+ "description": "Single-type at extreme: rat0 (atheist, hostile to religion)",
+ "uri": "ugi:0@t21:gcs,rat0",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t21 Gcs\nRat0\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t21",
+ "fields": {
+ "r": { "type": "single", "entries": [
+ {"sub_id": "at", "name": "Atheist", "value": 0, "label": "Hostile"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "languages_basic",
+ "description": "Languages field: ISO 639-1 codes + ratings (multi with 2-char fixed sub-IDs)",
+ "uri": "ugi:0@t22:gcs,lar7en6",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t22 Gcs\nLar7 Len6\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t22",
+ "fields": {
+ "l": { "type": "multi", "entries": [
+ {"sub_id": "ar", "name": "Arabic", "value": 7, "label": "Native"},
+ {"sub_id": "en", "name": "English", "value": 6, "label": "Fluent"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "languages_with_modifiers",
+ "description": "Languages with $ and + modifiers",
+ "uri": "ugi:0@t23:gcs,lar7$en6de3+5",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t23 Gcs\nLar7$ Len6 Lde3+5\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t23",
+ "fields": {
+ "l": { "type": "multi", "entries": [
+ {"sub_id": "ar", "name": "Arabic", "value": 7, "label": "Native", "paid": true},
+ {"sub_id": "en", "name": "English", "value": 6, "label": "Fluent"},
+ {"sub_id": "de", "name": "German", "value": 3, "label": "Beginner", "aspire": 5}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "multi_os_grouped",
+ "description": "OS field with sub-IDs from multiple groups (linux + bsd + windows)",
+ "uri": "ugi:0@t24:gcs,oxa7$fb5we3",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t24 Gcs\nOxa7$ Ofb5 Owe3\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t24",
+ "fields": {
+ "o": { "type": "multi", "entries": [
+ {"sub_id": "xa", "name": "Arch", "value": 7, "label": "Master / creator", "paid": true},
+ {"sub_id": "fb", "name": "FreeBSD", "value": 5, "label": "Competent"},
+ {"sub_id": "we", "name": "Windows 11", "value": 3, "label": "Beginner"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "editor_with_aspire",
+ "description": "Editor field with aspire modifier (dvi7em3+6)",
+ "uri": "ugi:0@t25:gcs,dvi7em3+6",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t25 Gcs\nDvi7 Dem3+6\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t25",
+ "fields": {
+ "d": { "type": "multi", "entries": [
+ {"sub_id": "vi", "name": "Vim / Neovim", "value": 7, "label": "Master / creator"},
+ {"sub_id": "em", "name": "Emacs", "value": 3, "label": "Beginner", "aspire": 6}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "hair_custom_scale",
+ "description": "Hair field uses custom scale_labels (hh5f7b3)",
+ "uri": "ugi:0@t26:gcs,hh5f7b3",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t26 Gcs\nHh5 Hf7 Hb3\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t26",
+ "fields": {
+ "h": { "type": "multi", "entries": [
+ {"sub_id": "h", "name": "Head", "value": 5, "label": "Above average"},
+ {"sub_id": "f", "name": "Face / beard", "value": 7, "label": "Sasquatch"},
+ {"sub_id": "b", "name": "Brows", "value": 3, "label": "Below average"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "comics_enthusiasm_scale",
+ "description": "Comics field uses global enthusiasm scale (xxk7dc0)",
+ "uri": "ugi:0@t27:gcs,xxk7dc0",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t27 Gcs\nXxk7 Xdc0\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t27",
+ "fields": {
+ "x": { "type": "multi", "entries": [
+ {"sub_id": "xk", "name": "XKCD", "value": 7, "label": "Life-defining"},
+ {"sub_id": "dc", "name": "DC Comics", "value": 0, "label": "Despise"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "tv_grouped_subids",
+ "description": "TV field with sub-IDs from different groups (scifi + fantasy + other)",
+ "uri": "ugi:0@t28:gcs,tst7go5mr6",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t28 Gcs\nTst7 Tgo5 Tmr6\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t28",
+ "fields": {
+ "t": { "type": "multi", "entries": [
+ {"sub_id": "st", "name": "Star Trek", "value": 7, "label": "Life-defining"},
+ {"sub_id": "go", "name": "Game of Thrones", "value": 5, "label": "Enjoy"},
+ {"sub_id": "mr", "name": "Mr. Robot", "value": 6, "label": "Enthusiast"}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "gaming_paid_aspire",
+ "description": "Gaming with paid and aspire modifiers on different entries (jrp7$pc6st4+6)",
+ "uri": "ugi:0@t29:gcs,jrp7$pc6st4+6",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @t29 Gcs\nJrp7$ Jpc6 Jst4+6\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "t29",
+ "fields": {
+ "j": { "type": "multi", "entries": [
+ {"sub_id": "rp", "name": "RPG", "value": 7, "label": "Life-defining", "paid": true},
+ {"sub_id": "pc", "name": "PC", "value": 6, "label": "Enthusiast"},
+ {"sub_id": "st", "name": "Strategy", "value": 4, "label": "Take or leave", "aspire": 6}
+ ]}
+ }
+ }
+ },
+
+ {
+ "id": "all_direct_fields",
+ "description": "Every direct-type field at once",
+ "uri": "ugi:0@full:gcs,a4,c3,e5,f4,i5,k6,q4,s6,w5,y4,z3",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @full Gcs\nA4 E5\nC3\nW5 Q4\nI5\nK6\nS6 Y4 F4 Z3\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "full",
+ "fields": {
+ "a": { "type": "direct", "value": 4, "label": "25-34" },
+ "c": { "type": "direct", "value": 3, "label": "Jeans & tee" },
+ "e": { "type": "direct", "value": 5, "label": "Masters" },
+ "f": { "type": "direct", "value": 4, "label": "Home cook" },
+ "i": { "type": "direct", "value": 5, "label": "Positive" },
+ "k": { "type": "direct", "value": 6, "label": "Avid" },
+ "q": { "type": "direct", "value": 4, "label": "Password manager" },
+ "s": { "type": "direct", "value": 6, "label": "Partnered" },
+ "w": { "type": "direct", "value": 5, "label": "Comfortable" },
+ "y": { "type": "direct", "value": 4, "label": "Some exercise" },
+ "z": { "type": "direct", "value": 3, "label": "Handy" }
+ }
+ }
+ },
+
+ {
+ "id": "spec_example_jdoe",
+ "description": "Full example from spec: jdoe full-stack developer",
+ "uri": "ugi:0@jdoe:gcs$/dev,a4,b5/4,c3,hh4f5m4,e4,len7,oxa6we5,ppy6$js6$ts5rs4+6,dvs6,w5,q4,i5,v5/4,rag4,tst5ex7,k5,xxk6,jst5pc6,mrk5in4,s6,y4,f4,z3",
+ "block": "------- BEGIN UGI BLOCK -------\nv:0 @jdoe Gcs$/dev\nA4 E4 Len7\nB5/4 C3 Hh4 Hf5 Hm4\nOxa6 Owe5 Ppy6$ Pjs6$ Pts5 Prs4+6 Dvs6 W5 Q4\nI5 V5/4 Rag4\nTst5 Tex7 K5 Xxk6 Jst5 Jpc6 Mrk5 Min4\nS6 Y4 F4 Z3\n-------- END UGI BLOCK --------",
+ "decoded": {
+ "version": "0",
+ "handle": "jdoe",
+ "fields": {
+ "g": { "type": "special", "domains": [
+ {"id": "cs", "name": "Computer Science", "paid": true},
+ {"id": "dev", "name": "dev", "paid": false}
+ ]},
+ "a": { "type": "direct", "value": 4, "label": "25-34" },
+ "b": { "type": "special", "dimensions": {"height": {"value": 5, "label": "Above average"}, "width": {"value": 4, "label": "Average"}} },
+ "c": { "type": "direct", "value": 3, "label": "Jeans & tee" },
+ "h": { "type": "multi", "entries": [
+ {"sub_id": "h", "name": "Head", "value": 4, "label": "Average"},
+ {"sub_id": "f", "name": "Face / beard", "value": 5, "label": "Above average"},
+ {"sub_id": "m", "name": "Mustache", "value": 4, "label": "Average"}
+ ]},
+ "e": { "type": "direct", "value": 4, "label": "Bachelors" },
+ "l": { "type": "multi", "entries": [
+ {"sub_id": "en", "name": "English", "value": 7, "label": "Native"}
+ ]},
+ "o": { "type": "multi", "entries": [
+ {"sub_id": "xa", "name": "Arch", "value": 6, "label": "Advanced / daily"},
+ {"sub_id": "we", "name": "Windows 11", "value": 5, "label": "Competent"}
+ ]},
+ "p": { "type": "multi", "entries": [
+ {"sub_id": "py", "name": "Python", "value": 6, "label": "Advanced / daily", "paid": true},
+ {"sub_id": "js", "name": "JavaScript", "value": 6, "label": "Advanced / daily", "paid": true},
+ {"sub_id": "ts", "name": "TypeScript", "value": 5, "label": "Competent"},
+ {"sub_id": "rs", "name": "Rust", "value": 4, "label": "Average", "aspire": 6}
+ ]},
+ "d": { "type": "multi", "entries": [
+ {"sub_id": "vs", "name": "VS Code", "value": 6, "label": "Advanced / daily"}
+ ]},
+ "w": { "type": "direct", "value": 5, "label": "Comfortable" },
+ "q": { "type": "direct", "value": 4, "label": "Password manager" },
+ "i": { "type": "direct", "value": 5, "label": "Positive" },
+ "v": { "type": "special", "dimensions": {"social": {"value": 5, "label": "Center-left"}, "economic": {"value": 4, "label": "Mixed economy"}} },
+ "r": { "type": "single", "entries": [
+ {"sub_id": "ag", "name": "Agnostic", "value": 4, "label": "Cultural"}
+ ]},
+ "t": { "type": "multi", "entries": [
+ {"sub_id": "st", "name": "Star Trek", "value": 5, "label": "Enjoy"},
+ {"sub_id": "ex", "name": "Expanse", "value": 7, "label": "Life-defining"}
+ ]},
+ "k": { "type": "direct", "value": 5, "label": "Regular" },
+ "x": { "type": "multi", "entries": [
+ {"sub_id": "xk", "name": "XKCD", "value": 6, "label": "Enthusiast"}
+ ]},
+ "j": { "type": "multi", "entries": [
+ {"sub_id": "st", "name": "Strategy", "value": 5, "label": "Enjoy"},
+ {"sub_id": "pc", "name": "PC", "value": 6, "label": "Enthusiast"}
+ ]},
+ "m": { "type": "multi", "entries": [
+ {"sub_id": "rk", "name": "Rock", "value": 5, "label": "Enjoy"},
+ {"sub_id": "in", "name": "Indie", "value": 4, "label": "Take or leave"}
+ ]},
+ "s": { "type": "direct", "value": 6, "label": "Partnered" },
+ "y": { "type": "direct", "value": 4, "label": "Some exercise" },
+ "f": { "type": "direct", "value": 4, "label": "Home cook" },
+ "z": { "type": "direct", "value": 3, "label": "Handy" }
+ }
+ }
+ },
+
+ {
+ "id": "spec_example_hax0r",
+ "description": "Full example from spec: hax0r hardware hacker with version/revision",
+ "uri": "ugi:0/3@hax0r:gee$/rb/dy~solder,a5,b6/5,c2,hh3f6+7m5,e4,lja7en5,oxg7fb5,pc5ba6as7$,dem5,w7$,q6,i2,v3/3,rbu5,tbb6ff7,k5,xxk6dl5,jre7pc6bg5,mmt7pk6,s3,y5,f4,z7$",
+ "decoded": {
+ "version": "0/3",
+ "handle": "hax0r",
+ "fields": {
+ "g": { "type": "special", "domains": [
+ {"id": "ee", "name": "Electrical Eng.", "paid": true},
+ {"id": "rb", "name": "Robotics", "paid": false},
+ {"id": "dy~solder", "name": "dy~solder", "paid": false}
+ ]},
+ "a": { "type": "direct", "value": 5, "label": "35-49" },
+ "b": { "type": "special", "dimensions": {"height": {"value": 6, "label": "Tall"}, "width": {"value": 5, "label": "Above average"}} },
+ "c": { "type": "direct", "value": 2, "label": "Political tees" },
+ "h": { "type": "multi", "entries": [
+ {"sub_id": "h", "name": "Head", "value": 3, "label": "Below average"},
+ {"sub_id": "f", "name": "Face / beard", "value": 6, "label": "Impressive", "aspire": 7},
+ {"sub_id": "m", "name": "Mustache", "value": 5, "label": "Above average"}
+ ]},
+ "e": { "type": "direct", "value": 4, "label": "Bachelors" },
+ "l": { "type": "multi", "entries": [
+ {"sub_id": "ja", "name": "Japanese", "value": 7, "label": "Native"},
+ {"sub_id": "en", "name": "English", "value": 5, "label": "Proficient"}
+ ]},
+ "o": { "type": "multi", "entries": [
+ {"sub_id": "xg", "name": "Gentoo", "value": 7, "label": "Master / creator"},
+ {"sub_id": "fb", "name": "FreeBSD", "value": 5, "label": "Competent"}
+ ]},
+ "p": { "type": "multi", "entries": [
+ {"sub_id": "c", "name": "C", "value": 5, "label": "Competent"},
+ {"sub_id": "ba", "name": "Bash", "value": 6, "label": "Advanced / daily"},
+ {"sub_id": "as", "name": "Assembly", "value": 7, "label": "Master / creator", "paid": true}
+ ]},
+ "d": { "type": "multi", "entries": [
+ {"sub_id": "em", "name": "Emacs", "value": 5, "label": "Competent"}
+ ]},
+ "w": { "type": "direct", "value": 7, "label": "Builds everything", "paid": true },
+ "q": { "type": "direct", "value": 6, "label": "Encrypts everything" },
+ "i": { "type": "direct", "value": 2, "label": "Skeptical" },
+ "v": { "type": "special", "dimensions": {"social": {"value": 3, "label": "Center-right"}, "economic": {"value": 3, "label": "Center-right"}} },
+ "r": { "type": "single", "entries": [
+ {"sub_id": "bu", "name": "Buddhist", "value": 5, "label": "Moderate"}
+ ]},
+ "t": { "type": "multi", "entries": [
+ {"sub_id": "bb", "name": "Babylon 5", "value": 6, "label": "Enthusiast"},
+ {"sub_id": "ff", "name": "Firefly", "value": 7, "label": "Life-defining"}
+ ]},
+ "k": { "type": "direct", "value": 5, "label": "Regular" },
+ "x": { "type": "multi", "entries": [
+ {"sub_id": "xk", "name": "XKCD", "value": 6, "label": "Enthusiast"},
+ {"sub_id": "dl", "name": "Dilbert", "value": 5, "label": "Enjoy"}
+ ]},
+ "j": { "type": "multi", "entries": [
+ {"sub_id": "re", "name": "Retro", "value": 7, "label": "Life-defining"},
+ {"sub_id": "pc", "name": "PC", "value": 6, "label": "Enthusiast"},
+ {"sub_id": "bg", "name": "Board games", "value": 5, "label": "Enjoy"}
+ ]},
+ "m": { "type": "multi", "entries": [
+ {"sub_id": "mt", "name": "Metal", "value": 7, "label": "Life-defining"},
+ {"sub_id": "pk", "name": "Punk", "value": 6, "label": "Enthusiast"}
+ ]},
+ "s": { "type": "direct", "value": 3, "label": "Complicated" },
+ "y": { "type": "direct", "value": 5, "label": "Regular workouts" },
+ "f": { "type": "direct", "value": 4, "label": "Home cook" },
+ "z": { "type": "direct", "value": 7, "label": "Built my house", "paid": true }
+ }
+ }
+ }
+ ]
+}
diff --git a/spec/ugi_registry_v0.json b/spec/ugi_registry_v0.json
new file mode 100644
index 0000000..6f28700
--- /dev/null
+++ b/spec/ugi_registry_v0.json
@@ -0,0 +1,134 @@
+{
+ "spec": {
+ "name": "UGI",
+ "full_name": "Unified Geek Identifier",
+ "version": "0",
+ "status": "draft",
+ "date": "2026-03-28",
+ "license": "MIT",
+ "description": "A compact, URI-safe self-description code for geeks. Encodes identity, skills, interests, and opinions into a single string suitable for bios, email signatures, and URIs.",
+ "predecessors": [
+ { "name": "Geek Code", "author": "Robert A. Hayden", "years": "1993-1996", "url": "https://sig.codes/geek/", "continuation": { "name": "Geek Code 4.0+", "author": "exarobibliologist", "years": "2019-2026", "url": "https://sig.codes/geek-4.0/" } },
+ { "name": "Hacker Key", "author": "Chris Allegretta", "years": "2003-2006", "url": "https://sig.codes/hacker-key/" }
+ ],
+ "references": [
+ { "title": "sig.codes", "url": "https://sig.codes/" },
+ { "title": "Geek Code on Wikipedia", "url": "https://en.wikipedia.org/wiki/Geek_Code" },
+ { "title": "Geek Code v3.12 mirror", "url": "https://geekcode.xyz/" },
+ { "title": "Hacker Key v4 mirror", "url": "https://sig.codes/hacker-key/4/" }
+ ]
+ },
+ "format": {
+ "uri": { "template": "ugi:<version>[/<revision>]@<handle>:<field>,<field>,...", "separator": ",", "prefix": "ugi:" },
+ "block": { "header": "------- BEGIN UGI BLOCK -------", "footer": "-------- END UGI BLOCK --------", "first_line": "v:<version>[/<revision>] @<handle> [G<geek_types>]", "separator": " " },
+ "case_sensitive": false,
+ "mandatory_fields": ["g"],
+ "mandatory_header": ["handle"],
+ "reserved_codes": ["n", "u"]
+ },
+ "scale": {
+ "type": "octal", "range": [0, 7],
+ "values": [
+ { "value": 0, "general": "Hostile / opposed", "proficiency": "Can't, won't", "enthusiasm": "Despise", "stance": "Strongly against" },
+ { "value": 1, "general": "Strong dislike", "proficiency": "Aware it exists", "enthusiasm": "Avoid actively", "stance": "Against" },
+ { "value": 2, "general": "Dislike / minimal", "proficiency": "Dabbled briefly", "enthusiasm": "Not interested", "stance": "Lean against" },
+ { "value": 3, "general": "Slight negative", "proficiency": "Beginner", "enthusiasm": "Cold", "stance": "Slightly against" },
+ { "value": 4, "general": "Baseline / neutral", "proficiency": "Average", "enthusiasm": "Take or leave", "stance": "No opinion" },
+ { "value": 5, "general": "Moderate positive", "proficiency": "Competent", "enthusiasm": "Enjoy", "stance": "Lean toward" },
+ { "value": 6, "general": "Strong positive", "proficiency": "Advanced / daily", "enthusiasm": "Enthusiast", "stance": "Support" },
+ { "value": 7, "general": "Obsessed / defining", "proficiency": "Master / creator", "enthusiasm": "Life-defining", "stance": "Evangelist" }
+ ]
+ },
+ "modifiers": {
+ "paid": { "symbol": "$", "description": "Earns money doing this", "example": "ppy6$" },
+ "aspire": { "symbol": "+", "description": "Aspiring toward target level", "example": "prs5+7", "format": "+<digit>" },
+ "paid_aspire": { "symbol": "$+", "description": "Paid and aspiring", "example": "pjs5$+7" },
+ "alternative": { "symbol": "/", "description": "Fluctuates between values (direct fields) or separator (g, b, v)", "example": "c3/6" },
+ "custom": { "symbol": "~", "description": "Custom sub-ID prefix", "example": "m~synthwave6" }
+ },
+ "fields": {
+ "a": { "name": "Age", "category": "identity", "type": "direct", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "Under 10", "humor": "Still loading. Baby Shark has more life experience." }, "1": { "label": "10-14", "humor": "Old enough for Linux opinions, too young to drive to the meetup." }, "2": { "label": "15-19", "humor": "Convinced you know everything. Might be right about the tech stuff." }, "3": { "label": "20-24", "humor": "Student loans arrived but so has 3am pizza freedom." }, "4": { "label": "25-34", "humor": "Peak earning-to-caring ratio. GitHub green, back complaining." }, "5": { "label": "35-49", "humor": "You remember dial-up noises. Kids ask what a floppy disk is." }, "6": { "label": "50-64", "humor": "You were there when it happened. All of it." }, "7": { "label": "65+", "humor": "Living legend. Your software runs on OSes born decades after you." } } },
+ "b": { "name": "Build", "category": "appearance", "type": "special", "format": "<height>/<width>", "modifiers": { "paid": false, "aspire": true, "alternative": false }, "dimensions": { "height": { "values": { "0": "Extremely short", "1": "Very short", "2": "Short", "3": "Below average", "4": "Average", "5": "Above average", "6": "Tall", "7": "Extremely tall" } }, "width": { "values": { "0": "Extremely thin", "1": "Very thin", "2": "Slim", "3": "Below average", "4": "Average", "5": "Above average", "6": "Broad", "7": "Extremely broad" } } } },
+ "c": { "name": "Clothing", "category": "appearance", "type": "direct", "modifiers": { "paid": false, "aspire": true, "alternative": true }, "scale_type": "custom", "values": { "0": { "label": "Nudist", "humor": "Quite a fashion statement." }, "1": { "label": "Punk", "humor": "Safety pins are load-bearing." }, "2": { "label": "Political tees", "humor": "Your shirts have opinions so you don't have to." }, "3": { "label": "Jeans & tee", "humor": "Universal geek uniform. You own seven identical shirts." }, "4": { "label": "Average", "humor": "Inoffensive. Forgettable. Exactly as intended." }, "5": { "label": "Smart casual", "humor": "You've mastered the blazer-over-tee." }, "6": { "label": "Formal", "humor": "You iron things. On purpose." }, "7": { "label": "Suit daily", "humor": "CEO, lawyer, or cosplaying as one." } } },
+ "d": { "name": "Editor / IDE", "category": "tech", "type": "multi", "modifiers": { "paid": false, "aspire": true, "alternative": false }, "scale_type": "proficiency", "humor_scale": { "0": "Opened it by accident, couldn't close it.", "7": "The editor is an extension of my nervous system." }, "sub_ids": { "vi": { "long": "vim", "name": "Vim / Neovim" }, "em": { "long": "emacs", "name": "Emacs" }, "vs": { "long": "vscode", "name": "VS Code" }, "ij": { "long": "idea", "name": "IntelliJ / JetBrains" }, "xc": { "long": "xcode", "name": "Xcode" }, "vt": { "long": "vstudio", "name": "Visual Studio" }, "ed": { "name": "ed" }, "na": { "long": "nano", "name": "nano / pico" }, "su": { "long": "sublime", "name": "Sublime Text" }, "ec": { "long": "eclipse", "name": "Eclipse" }, "np": { "long": "npp", "name": "Notepad++" }, "hx": { "long": "helix", "name": "Helix" }, "ze": { "long": "zed", "name": "Zed" }, "ka": { "long": "kakoune", "name": "Kakoune" }, "lp": { "long": "lapce", "name": "Lapce" }, "at": { "long": "atom", "name": "Atom" }, "nv": { "long": "nvchad", "name": "NvChad / LazyVim" } } },
+ "e": { "name": "Education", "category": "identity", "type": "direct", "modifiers": { "paid": false, "aspire": true, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "None / primary", "humor": "School of life. And YouTube." }, "1": { "label": "Middle school", "humor": "Survived. Emotionally, jury's still out." }, "2": { "label": "High school", "humor": "They said best years. They lied." }, "3": { "label": "Trade / associates", "humor": "You can actually do things, unlike philosophy majors." }, "4": { "label": "Bachelors", "humor": "Four years and paper that cost more than your car." }, "5": { "label": "Masters", "humor": "First degree wasn't enough to convince anyone." }, "6": { "label": "PhD", "humor": "Call me Doctor. Especially at parties." }, "7": { "label": "Post-doc", "humor": "Forgotten what sunlight looks like." } } },
+ "f": { "name": "Food / Cooking", "category": "lifestyle", "type": "direct", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "Microwave only", "humor": "Smoke detector is your kitchen timer." }, "1": { "label": "Boils water", "humor": "Sometimes remembers to turn off stove." }, "2": { "label": "Basic meals", "humor": "Eggs and pasta. The two food groups." }, "3": { "label": "Follows recipes", "humor": "To the letter. Substitution is heresy." }, "4": { "label": "Home cook", "humor": "Friends don't dread your dinner invitations." }, "5": { "label": "Experiments", "humor": "Sometimes works. You pretend you planned it." }, "6": { "label": "Hosts dinners", "humor": "Spice rack has subdivisions." }, "7": { "label": "Chef level", "humor": "Own a mandoline, all fingers intact." } } },
+ "g": { "name": "Geek Specialization", "category": "identity", "type": "special", "format": "domain[/$]/domain/~custom", "separator": "/", "modifiers": { "paid": true, "aspire": false, "alternative": false }, "mandatory": true, "description": "/ separates domains, ~ prefixes custom, $ after domain = paid. No ratings.", "sub_id_groups": { "computing": { "label": "Computing & Tech", "sub_ids": { "cs": "Computer Science", "ai": "Artificial Intelligence", "ml": "Machine Learning", "cy": "Cybersecurity", "ds": "Data Science", "bc": "Blockchain", "io": "Internet of Things", "qc": "Quantum Computing", "vr": "Virtual Reality", "os": "Operating Systems", "dv": "DevOps", "ap": "API Development", "cl": "Cloud Infrastructure", "eg": "Edge Computing", "sc": "Ethical Hacking", "ux": "User Experience", "wp": "Web Performance", "db": "Databases", "em": "Embedded Systems", "fp": "Functional Programming", "nx": "Networking", "cv": "Computer Vision", "nl": "NLP / Comp. Linguistics", "cr": "Cryptography" } }, "science": { "label": "Science & Medicine", "sub_ids": { "bi": "Biology", "cm": "Chemistry", "ph": "Physics", "md": "Medicine", "nr": "Neuroscience", "ev": "Environmental", "fs": "Forensics", "fd": "Food Science", "as": "Astronomy", "gl": "Geology", "bt": "Biotech", "gn": "Genetics" } }, "engineering": { "label": "Engineering & Making", "sub_ids": { "ee": "Electrical Eng.", "me": "Mechanical Eng.", "rb": "Robotics", "dy": "DIY / Maker", "tp": "3D Printing", "ar": "Arduino", "ce": "Civil Eng.", "ae": "Aerospace Eng.", "am": "Additive Mfg.", "ct": "Control Systems" } }, "academic": { "label": "Academic & Humanities", "sub_ids": { "mt": "Mathematics", "pl": "Philosophy", "ed": "Education", "lb": "Library Science", "lw": "Law", "ss": "Social Science", "py": "Psychology", "cg": "Cognitive Science", "li": "Linguistics", "hs": "History", "an": "Anthropology", "ec": "Economics" } }, "art": { "label": "Art & Design", "sub_ids": { "gd": "Graphic Design", "ad": "Animation", "fa": "Fine Arts", "mu": "Music", "pa": "Performing Arts", "sr": "Screenwriting", "tw": "Tech Writing", "wr": "Writing", "vd": "Game Design", "pg": "Photography", "tx": "Textile Arts", "ty": "Typography" } }, "business": { "label": "Business & Comms", "sub_ids": { "bz": "Business", "mk": "Marketing", "gv": "Government", "lo": "Logistics", "qa": "QA", "pm": "Project Mgmt", "hr": "HR" } }, "culture": { "label": "Culture & Society", "sub_ids": { "mm": "Memes", "fn": "Fandom", "pp": "Pop Culture", "gn": "Gender Studies", "al": "Alt Lifestyles" } }, "games_media": { "label": "Games & Media", "sub_ids": { "rp": "RPG", "sm": "Streaming", "pd": "Podcasting", "cw": "Cosplay" } }, "futurism": { "label": "Futurism", "sub_ids": { "ax": "Aerospace", "ft": "Futurism", "ali": "Alien Theories", "tr": "Transhumanism" } } } },
+ "h": { "name": "Hair", "category": "appearance", "type": "multi", "modifiers": { "paid": false, "aspire": true, "alternative": false }, "scale_type": "custom", "scale_labels": { "0": "Alopecia", "1": "Shaved", "2": "Stubble", "3": "Below average", "4": "Average", "5": "Above average", "6": "Impressive", "7": "Sasquatch" }, "sub_ids": { "h": { "name": "Head" }, "f": { "name": "Face / beard" }, "b": { "name": "Brows" }, "m": { "name": "Mustache" }, "s": { "name": "Sideburns" } } },
+ "i": { "name": "AI Stance", "category": "stance", "type": "direct", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "Butlerian Jihadist", "humor": "Would unplug every GPU on earth." }, "1": { "label": "Strongly opposed", "humor": "'No AI' in bio, code, and dating profile." }, "2": { "label": "Skeptical", "humor": "Prefer human-authored everything." }, "3": { "label": "Cautious", "humor": "Ethics papers keep you up at night." }, "4": { "label": "Neutral tool", "humor": "Like a hammer. No opinions about hammers." }, "5": { "label": "Positive", "humor": "Copilot saved 20 minutes today." }, "6": { "label": "Enthusiast", "humor": "Cyborg partnership workflow." }, "7": { "label": "Singularity evangelist", "humor": "Grocery list is AI-generated." } } },
+ "j": { "name": "Gaming", "category": "entertainment", "type": "multi", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "enthusiasm", "humor_scale": { "0": "Video games are a waste of time.", "7": "People disconnect when they see your name." }, "sub_id_groups": { "genre": { "label": "Genre", "sub_ids": { "rp": { "long": "rpg", "name": "RPG" }, "fp": { "long": "fps", "name": "Shooter" }, "st": { "long": "str", "name": "Strategy" }, "pz": { "long": "pzl", "name": "Puzzle" }, "si": { "long": "sim", "name": "Simulation" }, "mm": { "long": "mmo", "name": "MMO" }, "ad": { "long": "adv", "name": "Adventure" }, "sp": { "long": "spo", "name": "Sports" }, "mb": { "long": "mba", "name": "Metroidvania" }, "ro": { "long": "rog", "name": "Roguelike" }, "su": { "long": "surv", "name": "Survival" }, "sa": { "long": "sand", "name": "Sandbox" }, "rr": { "long": "race", "name": "Racing" }, "fi": { "long": "fight", "name": "Fighting" }, "ho": { "long": "horror", "name": "Horror" } } }, "platform": { "label": "Platform", "sub_ids": { "pc": { "name": "PC" }, "ps": { "name": "PlayStation" }, "xb": { "long": "xbox", "name": "Xbox" }, "ns": { "long": "switch", "name": "Switch" }, "mo": { "long": "mobile", "name": "Mobile" }, "re": { "long": "retro", "name": "Retro" }, "vr": { "name": "VR" }, "sd": { "long": "steamdeck", "name": "Steam Deck" } } }, "tabletop": { "label": "Tabletop", "sub_ids": { "dn": { "long": "dnd", "name": "D&D" }, "pf": { "long": "path", "name": "Pathfinder" }, "cc": { "long": "coc", "name": "Call of Cthulhu" }, "sr": { "name": "Shadowrun" }, "wo": { "long": "wod", "name": "World of Darkness" }, "ft": { "long": "fate", "name": "Fate" }, "gu": { "long": "gurps", "name": "GURPS" }, "bg": { "name": "Board games" }, "mt": { "long": "mtg", "name": "Magic: TG" }, "wh": { "name": "Warhammer" } } } } },
+ "k": { "name": "Books", "category": "entertainment", "type": "direct", "modifiers": { "paid": false, "aspire": true, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "Illiterate by choice", "humor": "Words are tiny drawings that haven't tried hard enough." }, "1": { "label": "Not since school", "humor": "Didn't finish that one either." }, "2": { "label": "Web only", "humor": "You call this 'reading.'" }, "3": { "label": "Occasional", "humor": "Mostly when trapped on a plane." }, "4": { "label": "Few/year", "humor": "Goodreads updated sporadically." }, "5": { "label": "Regular", "humor": "To-read pile doubles as furniture." }, "6": { "label": "Avid", "humor": "Library cards from multiple jurisdictions." }, "7": { "label": "Book/week", "humor": "Shelves have shelves. Read this spec for fun." } } },
+ "l": { "name": "Spoken Languages", "category": "identity", "type": "multi", "format": "ISO 639-1 + rating", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "custom", "scale_labels": { "0": "None", "1": "Few words", "2": "Tourist", "3": "Beginner", "4": "Conversational", "5": "Proficient", "6": "Fluent", "7": "Native" }, "common_codes": { "en": "English", "es": "Spanish", "fr": "French", "de": "German", "zh": "Chinese", "ar": "Arabic", "ja": "Japanese", "ko": "Korean", "pt": "Portuguese", "ru": "Russian", "hi": "Hindi", "it": "Italian", "nl": "Dutch", "sv": "Swedish", "tr": "Turkish", "pl": "Polish", "uk": "Ukrainian", "fa": "Farsi", "he": "Hebrew", "th": "Thai", "vi": "Vietnamese", "id": "Indonesian", "ms": "Malay", "sw": "Swahili", "bn": "Bengali", "ta": "Tamil", "fi": "Finnish", "no": "Norwegian", "da": "Danish", "el": "Greek", "ro": "Romanian", "hu": "Hungarian", "cs": "Czech", "sk": "Slovak", "bg": "Bulgarian", "sr": "Serbian", "hr": "Croatian", "ka": "Georgian", "am": "Amharic", "ur": "Urdu" } },
+ "m": { "name": "Music", "category": "entertainment", "type": "multi", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "scale_type": "enthusiasm", "humor_scale": { "0": "This genre is an assault on sound.", "7": "This genre IS your personality." }, "sub_ids": { "rk": { "long": "rock", "name": "Rock" }, "mt": { "long": "metal", "name": "Metal" }, "pk": { "long": "punk", "name": "Punk" }, "jz": { "long": "jazz", "name": "Jazz" }, "bl": { "long": "blues", "name": "Blues" }, "fk": { "long": "folk", "name": "Folk" }, "cl": { "long": "clas", "name": "Classical" }, "el": { "long": "elec", "name": "Electronic" }, "hp": { "long": "hip", "name": "Hip-hop" }, "pp": { "long": "pop", "name": "Pop" }, "rb": { "long": "rnb", "name": "R&B" }, "cn": { "long": "coun", "name": "Country" }, "ab": { "long": "amb", "name": "Ambient" }, "pg": { "long": "prog", "name": "Progressive" }, "rg": { "long": "reg", "name": "Reggae" }, "in": { "long": "ind", "name": "Indie" }, "sl": { "long": "soul", "name": "Soul" }, "lt": { "long": "lat", "name": "Latin" }, "wr": { "long": "wrld", "name": "World" }, "dk": { "long": "dnb", "name": "Drum & Bass" }, "tr": { "long": "trap", "name": "Trap" }, "lo": { "long": "lofi", "name": "Lo-fi" }, "sy": { "long": "synth", "name": "Synthwave" }, "go": { "long": "gospel", "name": "Gospel" }, "op": { "long": "opera", "name": "Opera" }, "ga": { "long": "garage", "name": "Garage" } } },
+ "o": { "name": "Operating System", "category": "tech", "type": "multi", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "proficiency", "humor_scale": { "0": "Would mass-format every drive running this.", "7": "You ARE the sysadmin. First person plural." }, "sub_id_groups": { "linux": { "label": "Linux (x prefix)", "sub_ids": { "xa": { "long": "xarch", "name": "Arch" }, "xd": { "long": "xdeb", "name": "Debian" }, "xu": { "long": "xubu", "name": "Ubuntu" }, "xm": { "long": "xmint", "name": "Mint" }, "xp": { "long": "xpop", "name": "Pop!_OS" }, "xf": { "long": "xfed", "name": "Fedora" }, "xr": { "long": "xrh", "name": "Red Hat" }, "xc": { "long": "xcent", "name": "CentOS/Alma/Rocky" }, "xs": { "long": "xsuse", "name": "OpenSUSE" }, "xg": { "long": "xgen", "name": "Gentoo" }, "xj": { "long": "xmanj", "name": "Manjaro" }, "xsl": { "long": "xslk", "name": "Slackware" }, "xn": { "long": "xnix", "name": "NixOS" }, "xk": { "long": "xkali", "name": "Kali" }, "xv": { "long": "xvoid", "name": "Void" }, "xz": { "long": "xzor", "name": "Zorin" }, "xx": { "long": "xmx", "name": "MX Linux" }, "xt": { "long": "xtail", "name": "Tails" }, "xq": { "long": "xqube", "name": "Qubes" }, "xl": { "long": "xlfs", "name": "LFS" }, "xe": { "long": "xelem", "name": "Elementary" }, "xw": { "long": "xwsl", "name": "WSL" }, "xi": { "long": "ximmut", "name": "Immutable" }, "xb": { "long": "xbedrock", "name": "Bedrock" } } }, "bsd": { "label": "BSD", "sub_ids": { "fb": { "name": "FreeBSD" }, "ob": { "name": "OpenBSD" }, "nb": { "name": "NetBSD" }, "db": { "name": "DragonflyBSD" } } }, "windows": { "label": "Windows", "sub_ids": { "wt": { "long": "wten", "name": "Windows 10" }, "we": { "long": "welv", "name": "Windows 11" }, "ws": { "long": "wsrv", "name": "Win Server" } } }, "mac": { "label": "Mac", "sub_ids": { "mc": { "long": "mac", "name": "macOS" } } }, "mobile": { "label": "Mobile", "sub_ids": { "an": { "long": "android", "name": "Android" }, "io": { "long": "ios", "name": "iOS" }, "pm": { "long": "pmos", "name": "postmarketOS" }, "li": { "long": "lineage", "name": "LineageOS" } } }, "other": { "label": "Other", "sub_ids": { "sl": { "long": "sol", "name": "Solaris" }, "ax": { "long": "aix", "name": "AIX" }, "ch": { "long": "chrome", "name": "ChromeOS" }, "ha": { "long": "haiku", "name": "Haiku" }, "tm": { "long": "temple", "name": "TempleOS" }, "se": { "long": "serenity", "name": "SerenityOS" }, "rx": { "long": "redox", "name": "Redox" }, "pl": { "long": "plan9", "name": "Plan 9" } } } } },
+ "p": { "name": "Programming Languages", "category": "tech", "type": "multi", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "proficiency", "humor_scale": { "0": "Mass-produced negative opinions without writing a line.", "7": "You ARE the language. People cite your blog posts." }, "sub_ids": { "py": { "long": "python", "name": "Python" }, "rs": { "long": "rust", "name": "Rust" }, "c": { "name": "C" }, "cp": { "long": "cpp", "name": "C++" }, "cs": { "long": "csharp", "name": "C#" }, "jv": { "long": "java", "name": "Java" }, "kt": { "long": "kotlin", "name": "Kotlin" }, "sw": { "long": "swift", "name": "Swift" }, "ph": { "long": "php", "name": "PHP" }, "pl": { "long": "perl", "name": "Perl" }, "lu": { "long": "lua", "name": "Lua" }, "hs": { "long": "haskell", "name": "Haskell" }, "cj": { "long": "clj", "name": "Clojure" }, "er": { "long": "erlang", "name": "Erlang" }, "ex": { "long": "elixir", "name": "Elixir" }, "sc": { "long": "scala", "name": "Scala" }, "jl": { "long": "julia", "name": "Julia" }, "r": { "name": "R" }, "m": { "long": "matlab", "name": "MATLAB" }, "zg": { "long": "zig", "name": "Zig" }, "ni": { "long": "nim", "name": "Nim" }, "as": { "long": "asm", "name": "Assembly" }, "sq": { "long": "sql", "name": "SQL" }, "ba": { "long": "bash", "name": "Bash" }, "ps": { "long": "psh", "name": "PowerShell" }, "vb": { "name": "Visual Basic" }, "da": { "long": "dart", "name": "Dart" }, "oc": { "long": "objc", "name": "Obj-C" }, "ls": { "long": "lisp", "name": "Lisp" }, "js": { "name": "JavaScript" }, "ts": { "name": "TypeScript" }, "go": { "name": "Go" }, "rb": { "long": "ruby", "name": "Ruby" }, "ml": { "long": "ocaml", "name": "OCaml" }, "fs": { "long": "fsharp", "name": "F#" }, "pr": { "long": "prolog", "name": "Prolog" }, "co": { "long": "cobol", "name": "COBOL" }, "fo": { "long": "fortran", "name": "Fortran" }, "gd": { "long": "gdscript", "name": "GDScript" }, "vl": { "long": "vhdl", "name": "VHDL" }, "sv": { "long": "sysv", "name": "SystemVerilog" }, "cr": { "long": "crystal", "name": "Crystal" }, "hy": { "long": "hylang", "name": "Hy" }, "rk": { "long": "racket", "name": "Racket" }, "tc": { "long": "tcl", "name": "Tcl" }, "aw": { "long": "awk", "name": "AWK" }, "sd": { "long": "sed", "name": "sed" }, "wm": { "long": "wasm", "name": "WASM" } } },
+ "q": { "name": "Security / Hacking", "category": "tech", "type": "direct", "modifiers": { "paid": true, "aspire": true, "alternative": true }, "scale_type": "custom", "values": { "0": { "label": "No security", "humor": "Password is 'password'. Click every link." }, "1": { "label": "Don't care", "humor": "Firewalls are a construction thing." }, "2": { "label": "Default passwords", "humor": "Pet name plus birth year. Reused everywhere." }, "3": { "label": "Basic awareness", "humor": "Know phishing is bad. Fallen for it once." }, "4": { "label": "Password manager", "humor": "Normal human security." }, "5": { "label": "2FA everywhere", "humor": "Judge friends' passwords." }, "6": { "label": "Encrypts everything", "humor": "Paranoid but justified." }, "7": { "label": "Tinfoil hat", "humor": "NSA has a file but can't read it either." } } },
+ "r": { "name": "Religion", "category": "stance", "type": "single", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "scale_type": "custom", "scale_labels": { "0": "Hostile", "1": "Strongly negative", "2": "Lapsed", "3": "Indifferent", "4": "Cultural", "5": "Moderate", "6": "Devout", "7": "Clergy-level" }, "sub_ids": { "ch": { "long": "chr", "name": "Christian" }, "is": { "long": "isl", "name": "Islam" }, "ju": { "long": "jud", "name": "Judaism" }, "hi": { "long": "hin", "name": "Hindu" }, "bu": { "long": "bud", "name": "Buddhist" }, "sk": { "long": "sik", "name": "Sikh" }, "pg": { "long": "pag", "name": "Pagan" }, "de": { "long": "dei", "name": "Deist" }, "ag": { "long": "agn", "name": "Agnostic" }, "at": { "long": "ath", "name": "Atheist" }, "pn": { "long": "pan", "name": "Pantheist" }, "sp": { "long": "spi", "name": "Spiritual" }, "zo": { "long": "zor", "name": "Zoroastrian" }, "ja": { "long": "jain", "name": "Jain" }, "ta": { "long": "tao", "name": "Taoist" }, "sh": { "long": "shin", "name": "Shinto" }, "ba": { "long": "bah", "name": "Bahá'í" }, "dr": { "long": "druz", "name": "Druze" } } },
+ "s": { "name": "Sex / Relationships", "category": "lifestyle", "type": "direct", "modifiers": { "paid": false, "aspire": true, "alternative": true }, "scale_type": "custom", "values": { "0": { "label": "Celibate", "humor": "Made your peace." }, "1": { "label": "Not active (unwilling)", "humor": "Dating profile is unrewarded effort." }, "2": { "label": "Not active", "humor": "'Focusing on yourself.' Friends skeptical." }, "3": { "label": "Complicated", "humor": "Status requires a flowchart." }, "4": { "label": "Private", "humor": "None of your business." }, "5": { "label": "Dating", "humor": "At least two dating apps." }, "6": { "label": "Partnered", "humor": "Found someone who tolerates your geekery." }, "7": { "label": "Married w/ kids", "humor": "Evidence is small, loud, doesn't sleep." } } },
+ "t": { "name": "TV / Film", "category": "entertainment", "type": "multi", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "scale_type": "enthusiasm", "humor_scale": { "0": "Crime against storytelling.", "7": "Know the lore deeper than the writers." }, "sub_id_groups": { "scifi": { "label": "Sci-Fi", "sub_ids": { "st": { "long": "trek", "name": "Star Trek" }, "sw": { "long": "wars", "name": "Star Wars" }, "sg": { "long": "gate", "name": "Stargate" }, "ex": { "long": "exp", "name": "Expanse" }, "bs": { "long": "bsg", "name": "Battlestar" }, "bb": { "long": "bab", "name": "Babylon 5" }, "ff": { "name": "Firefly" }, "du": { "long": "dune", "name": "Dune" }, "fn": { "long": "fnd", "name": "Foundation" }, "dw": { "long": "drwho", "name": "Doctor Who" }, "tb": { "long": "tbp", "name": "3 Body Problem" }, "si": { "long": "silo", "name": "Silo" }, "fa": { "long": "fall", "name": "Fallout" }, "al": { "long": "alien", "name": "Alien" }, "bd": { "long": "blade", "name": "Blade Runner" }, "or": { "long": "orville", "name": "Orville" } } }, "fantasy": { "label": "Fantasy", "sub_ids": { "go": { "long": "got", "name": "Game of Thrones" }, "wo": { "long": "wot", "name": "Wheel of Time" }, "ro": { "long": "rop", "name": "Rings of Power" }, "wi": { "long": "wit", "name": "Witcher" }, "sn": { "long": "sand", "name": "Sandman" } } }, "other": { "label": "Other", "sub_ids": { "mp": { "long": "monty", "name": "Monty Python" }, "bt": { "long": "bbt", "name": "Big Bang Theory" }, "rm": { "name": "Rick & Morty" }, "bl": { "name": "Black Mirror" }, "sv": { "long": "sev", "name": "Severance" }, "mr": { "long": "mrobot", "name": "Mr. Robot" }, "hm": { "long": "humans", "name": "Humans" }, "ww": { "long": "westworld", "name": "Westworld" }, "dp": { "long": "devs", "name": "Devs" } } } } },
+ "v": { "name": "Politics", "category": "stance", "type": "special", "format": "<social>/<economic>", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "dimensions": { "social": { "values": { "0": "Far-right authoritarian", "1": "Strong conservative", "2": "Conservative", "3": "Center-right", "4": "Centrist", "5": "Center-left", "6": "Progressive", "7": "Far-left libertarian" } }, "economic": { "values": { "0": "Unregulated capitalism", "1": "Strong free market", "2": "Fiscal conservative", "3": "Center-right", "4": "Mixed economy", "5": "Social democrat", "6": "Democratic socialist", "7": "Full socialism" } } } },
+ "w": { "name": "Hardware", "category": "tech", "type": "direct", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "Defeated by USB", "humor": "The cable won." }, "1": { "label": "Can plug in", "humor": "Big hole is for power." }, "2": { "label": "Replaced a part", "humor": "Three YouTube videos and mild weeping." }, "3": { "label": "Troubleshoots", "humor": "Can tell RAM from a graphics card." }, "4": { "label": "Built a PC", "humor": "Cable management isn't great but it posted." }, "5": { "label": "Comfortable", "humor": "Friends call when things break." }, "6": { "label": "Mods regularly", "humor": "Void warranties recreationally." }, "7": { "label": "Builds everything", "humor": "Manufactured your own PCBs." } } },
+ "x": { "name": "Comics / Manga", "category": "entertainment", "type": "multi", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "scale_type": "enthusiasm", "humor_scale": { "0": "Comics are for children. (You're wrong.)", "7": "Collection requires its own room." }, "sub_ids": { "dc": { "name": "DC Comics" }, "mv": { "long": "marvel", "name": "Marvel" }, "mg": { "long": "manga", "name": "Manga" }, "dl": { "long": "dilbert", "name": "Dilbert" }, "xk": { "long": "xkcd", "name": "XKCD" }, "in": { "long": "indie", "name": "Indie" }, "wt": { "long": "webtoon", "name": "Webtoon" }, "gn": { "long": "graphicnovel", "name": "Graphic Novels" } } },
+ "y": { "name": "Fitness", "category": "lifestyle", "type": "direct", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "What is outside", "humor": "Cardiovascular exercise is walking to the fridge." }, "1": { "label": "Avoids activity", "humor": "Physical activity is a social obligation." }, "2": { "label": "Occasional walk", "humor": "Coffee is the point, not the walk." }, "3": { "label": "Light activity", "humor": "Own athletic wear. Never been to a gym." }, "4": { "label": "Some exercise", "humor": "Can climb stairs without existential crisis." }, "5": { "label": "Regular workouts", "humor": "Track your steps." }, "6": { "label": "Very active", "humor": "Resting heart rate impresses doctors." }, "7": { "label": "Daily athlete", "humor": "Body is a temple. Temple has a squat rack." } } },
+ "z": { "name": "Maker / DIY", "category": "lifestyle", "type": "direct", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "Defeated by IKEA", "humor": "Allen keys are your nemesis." }, "1": { "label": "IKEA once", "humor": "One screw left over. Probably fine." }, "2": { "label": "Basic repairs", "humor": "Holes approximately where they should be." }, "3": { "label": "Handy", "humor": "Fixes hold. Duct tape is structural." }, "4": { "label": "Simple projects", "humor": "A shelf here, a birdhouse there." }, "5": { "label": "Regular maker", "humor": "Projects folder never shrinks." }, "6": { "label": "Advanced", "humor": "Workshop has zones." }, "7": { "label": "Built my house", "humor": "Hardware store sends Christmas card." } } }
+ },
+ "examples": [
+ { "handle": "jdoe", "description": "Full-stack developer", "uri": "ugi:0@jdoe:gcs$/dev,a4,b5/4,c3,hh4f5m4,e4,len7,oxa6we5,ppy6$js6$ts5rs4+6,dvs6,w5,q4,i5,v5/4,rag4,tst5ex7,k5,xxk6,jst5pc6,mrk5in4,s6,y4,f4,z3" },
+ { "handle": "neo", "description": "Security researcher", "uri": "ugi:0@neo:gcy$/sec/os,a3,b5/3,c1,hh2,e6,len6zh4,oxd7$fb6,pc6$ba7as5rs5,ded7,w6,q7$,i1,v6/5,rat5,tff7bs6bl6,k7,jro6pc7,mmt6el5,s2,y5,f3,z6" },
+ { "handle": "hax0r", "description": "Hardware hacker", "uri": "ugi:0/3@hax0r:gee$/rb/dy~solder,a5,b6/5,c2,hh3f6+7m5,e4,lja7en5,oxg7fb5,pc5ba6as7$,dem5,w7$,q6,i2,v3/3,rbu5,tbb6ff7,k5,xxk6dl5,jre7pc6bg5,mmt7pk6,s3,y5,f4,z7$" }
+ ],
+ "comparison": {
+ "features": [
+ ["Rating", "+/- stacking", "+/- stacking", "0-9", "0-7 octal"],
+ ["Length", "4-6 lines", "4-8 lines", "1 line", "1 or 4-8"],
+ ["Fields", "~25", "~35+", "~16", "24+2"],
+ ["URI-safe", "No", "No", "Mostly", "Yes"],
+ ["Case", "Sensitive", "Sensitive", "Sensitive", "Insensitive"],
+ ["Extensible", "No", "No", "Limited", "~ custom"],
+ ["Format", "Block", "Block", "Inline", "Block+URI"],
+ ["Active", "1996", "GitHub", "2006", "Draft"]
+ ],
+ "retained": [
+ { "ugi": "a", "from": "both", "note": "Age brackets" },
+ { "ugi": "b", "from": "geek_code", "note": "Dimensions" },
+ { "ugi": "c", "from": "geek_code", "note": "Dress/clothing" },
+ { "ugi": "e", "from": "both", "note": "Education" },
+ { "ugi": "g", "from": "geek_code", "note": "Geek type, expanded" },
+ { "ugi": "h", "from": "geek_code_2026", "note": "Hair/beard" },
+ { "ugi": "k", "from": "both", "note": "Books" },
+ { "ugi": "o", "from": "both", "note": "OS merged" },
+ { "ugi": "p", "from": "both", "note": "Programming" },
+ { "ugi": "q", "from": "both", "note": "Security/PGP" },
+ { "ugi": "s", "from": "both", "note": "Sex" },
+ { "ugi": "t", "from": "geek_code", "note": "TV merged" },
+ { "ugi": "v", "from": "both", "note": "Politics" },
+ { "ugi": "w", "from": "hacker_key", "note": "Hardware" }
+ ],
+ "added": [
+ { "ugi": "d", "reason": "Editor war must be documented" },
+ { "ugi": "f", "reason": "Geeks cook now" },
+ { "ugi": "i", "reason": "Defining divide of the 2020s" },
+ { "ugi": "j", "reason": "Gaming beyond DOOM" },
+ { "ugi": "l", "reason": "Geek culture is global" },
+ { "ugi": "m", "reason": "Absent from predecessors" },
+ { "ugi": "r", "reason": "From Hacker Key" },
+ { "ugi": "x", "reason": "Beyond Dilbert" },
+ { "ugi": "y", "reason": "Modern geek exercises" },
+ { "ugi": "z", "reason": "Maker movement" }
+ ],
+ "removed": [
+ { "field": "Usenet", "reason": "Dead" },
+ { "field": "Kibo", "reason": "Niche" },
+ { "field": "DOOM", "reason": "Use j" },
+ { "field": "OS/2, VMS", "reason": "Use o+~custom" },
+ { "field": "MBTI", "reason": "Pop psychology" },
+ { "field": "Residence", "reason": "Low value" },
+ { "field": "GitHub handle", "reason": "@handle" },
+ { "field": "I/O", "reason": "Abstract" },
+ { "field": "Cracking", "reason": "Use q" },
+ { "field": "Lang hacking", "reason": "In g" },
+ { "field": "Math level", "reason": "In e+g" },
+ { "field": "Prog methodology", "reason": "In p" }
+ ]
+ }
+}
diff --git a/web/LICENSE b/web/LICENSE
new file mode 100644
index 0000000..dd35f66
--- /dev/null
+++ b/web/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Ahmed Mohamed Alaa
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..50e8cef
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,46 @@
+# ugi.gumx.cc
+
+Web-based UGI (Unified Geek Identifier) encoder, decoder, and format converter.
+
+## What is this?
+
+A self-contained HTML tool for working with UGI strings — the modernized Geek Code. It provides three functions:
+
+- **Encoder** — form-based interface to build a UGI string by selecting fields, sub-IDs, and ratings
+- **Decoder** — paste a UGI string (URI or block format) and see a human-readable breakdown
+- **Converter** — convert between URI format (`ugi:0@handle:...`) and block format
+
+The tool is a single HTML file with no external dependencies. The full UGI registry is embedded inline.
+
+## Usage
+
+Open `index.html` in any browser. No server required.
+
+Supports `prefers-color-scheme` for automatic dark mode.
+
+## Regenerating
+
+The generator script takes the registry JSON path and output file path as arguments:
+
+```bash
+python scripts/gen_tool.py /path/to/ugi_registry_v0.json index.html
+```
+
+### Requirements
+
+- Python 3.8+
+- No external dependencies
+
+## Project structure
+
+```
+ugi.gumx.cc/
+├── index.html # generated tool (deploy this)
+├── scripts/
+│ └── gen_tool.py # HTML tool generator
+└── README.md
+```
+
+## License
+
+MIT
diff --git a/web/scripts/gen_tool.py b/web/scripts/gen_tool.py
new file mode 100644
index 0000000..232a7eb
--- /dev/null
+++ b/web/scripts/gen_tool.py
@@ -0,0 +1,1388 @@
+#!/usr/bin/env python3
+"""Generate UGI_TOOL_v0.html from ugi_registry_v0.json."""
+
+import json
+import os
+import sys
+import html
+
+
+def load_registry(path):
+ with open(path, "r") as f:
+ return json.load(f)
+
+
+def generate_html(reg):
+ reg_json = json.dumps(reg, ensure_ascii=False)
+
+ return f"""<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>UGI Tool v{reg['spec']['version']} — Encoder / Decoder / Converter</title>
+<style>
+@font-face {{ font-family: "Kawkab Mono"; src: url(fonts/KawkabMono-Regular.woff2); font-weight: normal; }}
+@font-face {{ font-family: "Kawkab Mono"; src: url(fonts/KawkabMono-Bold.woff2); font-weight: bold; }}
+* {{ box-sizing: border-box; margin: 0; padding: 0; unicode-bidi: plaintext; }}
+html {{ color: black; background-color: white; }}
+body {{
+ font-family: "Kawkab Mono", monospace;
+ font-size: 14px; line-height: 1.5;
+ max-width: 900px; margin: 0 auto; padding: 1rem;
+}}
+h1 {{ font-size: 1.3rem; margin-bottom: 0.5rem; }}
+h2 {{ font-size: 1.1rem; margin: 1rem 0 0.5rem; }}
+h3 {{ font-size: 0.95rem; margin: 0.8rem 0 0.3rem; }}
+hr {{ border: none; border-top: 1px solid; margin: 1rem 0; }}
+a {{ color: inherit; }}
+@media (prefers-color-scheme: dark) {{ html {{ filter: invert(1); }} img {{ filter: invert(1); }} }}
+.tabs {{
+ display: flex; gap: 0; border-bottom: 2px solid; margin-bottom: 1rem;
+}}
+.tab {{
+ padding: 0.4rem 1rem; cursor: pointer; border: 1px solid transparent;
+ border-bottom: none; background: rgba(128,128,128,0.1);
+ font-family: inherit; font-size: inherit;
+}}
+.tab.active {{ background: white; border-color: currentColor; border-bottom: 1px solid white; margin-bottom: -2px; font-weight: bold; }}
+.tab-content {{ display: none; }}
+.tab-content.active {{ display: block; }}
+label {{ display: block; margin: 0.3rem 0 0.1rem; font-weight: bold; }}
+input, select, textarea {{
+ font-family: inherit; font-size: inherit;
+ border: 1px solid;
+ padding: 0.3rem 0.5rem; border-radius: 3px;
+}}
+input[type="range"] {{ background: none; border: none; padding: 0; width: 200px; vertical-align: middle; }}
+input[type="checkbox"] {{ width: auto; margin-right: 0.3rem; vertical-align: middle; }}
+textarea {{ width: 100%; min-height: 100px; resize: vertical; }}
+button {{
+ font-family: inherit; font-size: inherit; padding: 0.3rem 0.8rem;
+ cursor: pointer; background: black; color: white; border: none;
+ border-radius: 3px;
+}}
+button:hover {{ opacity: 0.7; }}
+.field-section {{ margin: 0.5rem 0; padding: 0.5rem; border: 1px solid; border-radius: 3px; }}
+.field-section summary {{ cursor: pointer; font-weight: bold; }}
+.chip {{
+ display: inline-block; padding: 0.15rem 0.5rem; margin: 0.15rem;
+ background: rgba(128,128,128,0.15); border-radius: 3px; font-size: 0.85rem;
+}}
+.chip .remove {{ cursor: pointer; margin-left: 0.3rem; }}
+.output-box {{
+ background: rgba(128,128,128,0.08); padding: 0.8rem; border-radius: 3px;
+ word-break: break-all; margin: 0.5rem 0; min-height: 2rem;
+ border: 1px solid;
+}}
+.decode-table {{ width: 100%; border-collapse: collapse; margin: 0.5rem 0; }}
+.decode-table th, .decode-table td {{
+ text-align: left; padding: 0.3rem 0.5rem; border: 1px solid;
+}}
+.decode-table th {{ background: rgba(128,128,128,0.1); }}
+.humor {{ font-style: italic; font-size: 0.85rem; }}
+.slider-row {{ display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }}
+.slider-val {{ min-width: 1.5rem; text-align: center; font-weight: bold; }}
+.slider-label {{ font-size: 0.85rem; }}
+.multi-add {{ display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin: 0.3rem 0; }}
+details {{ margin: 0.5rem 0; }}
+details summary {{ cursor: pointer; }}
+.skip-check {{ margin-left: 0.5rem; font-weight: normal; font-size: 0.85rem; }}
+footer {{ text-align: center; margin-top: 3rem; font-size: 0.85rem; }}
+</style>
+</head>
+<body>
+
+<h1>UGI Tool v{reg['spec']['version']}</h1>
+<p>{html.escape(reg['spec']['description'])}</p>
+<hr>
+
+<div class="tabs">
+ <button class="tab active" onclick="switchTab('encoder')">Encoder</button>
+ <button class="tab" onclick="switchTab('decoder')">Decoder</button>
+ <button class="tab" onclick="switchTab('converter')">Converter</button>
+ <button class="tab" onclick="switchTab('spec')">Spec</button>
+</div>
+
+<div id="tab-encoder" class="tab-content active">
+ <h2>Encode your UGI</h2>
+
+ <label>Handle (required):</label>
+ <input type="text" id="enc-handle" placeholder="yourhandle" oninput="encodeUGI()">
+
+ <label>Version:</label>
+ <div class="slider-row">
+ <input type="text" id="enc-version" value="0" size="5" oninput="encodeUGI()">
+ </div>
+
+ <div id="enc-fields"></div>
+
+ <hr>
+ <h3>URI output:</h3>
+ <div class="output-box" id="enc-uri-output"></div>
+ <button onclick="copyOutput('enc-uri-output')">Copy URI</button>
+
+ <h3>Block output:</h3>
+ <pre class="output-box" id="enc-block-output"></pre>
+ <button onclick="copyOutput('enc-block-output')">Copy Block</button>
+</div>
+
+<div id="tab-decoder" class="tab-content">
+ <h2>Decode a UGI string</h2>
+ <label>Paste UGI (URI or block):</label>
+ <textarea id="dec-input" placeholder="ugi:0@jdoe:gcs$/dev,a4,b5/4,..." oninput="decodeUGI()"></textarea>
+ <div id="dec-output"></div>
+</div>
+
+<div id="tab-converter" class="tab-content">
+ <h2>Convert between formats</h2>
+ <label>Paste either format:</label>
+ <textarea id="conv-input" placeholder="Paste URI or block format..." oninput="convertUGI()"></textarea>
+ <h3>Converted output:</h3>
+ <pre class="output-box" id="conv-output"></pre>
+ <button onclick="copyOutput('conv-output')">Copy</button>
+</div>
+
+<div id="tab-spec" class="tab-content">
+ <div id="spec-content"></div>
+</div>
+
+<hr>
+<details>
+ <summary><strong>Quick Reference</strong></summary>
+ <pre id="quick-ref"></pre>
+</details>
+
+<script>
+const REG = {reg_json};
+
+/* === Tab switching === */
+function switchTab(name) {{
+ document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
+ document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
+ document.getElementById('tab-' + name).classList.add('active');
+ document.querySelector('[onclick="switchTab(\\'' + name + '\\')"]').classList.add('active');
+}}
+
+/* === Utility === */
+function copyOutput(id) {{
+ const text = document.getElementById(id).textContent;
+ navigator.clipboard.writeText(text);
+}}
+
+function getScale(field) {{
+ const f = REG.fields[field];
+ if (!f) return {{}};
+ if (f.values) return f.values;
+ if (f.scale_labels) {{
+ const out = {{}};
+ for (const [k,v] of Object.entries(f.scale_labels)) out[k] = {{label: v}};
+ return out;
+ }}
+ const st = f.scale_type;
+ if (st && st !== 'custom') {{
+ const out = {{}};
+ for (const v of REG.scale.values) out[v.value] = {{label: v[st] || v.general}};
+ return out;
+ }}
+ return {{}};
+}}
+
+function getSubIds(field) {{
+ const f = REG.fields[field];
+ if (!f) return [];
+ const out = [];
+ if (f.sub_ids) {{
+ for (const [k,v] of Object.entries(f.sub_ids)) {{
+ const name = typeof v === 'object' ? (v.name || k) : v;
+ out.push({{code: k, name}});
+ }}
+ }}
+ if (f.sub_id_groups) {{
+ for (const [gk, group] of Object.entries(f.sub_id_groups)) {{
+ const label = group.label || gk;
+ for (const [k,v] of Object.entries(group.sub_ids)) {{
+ const name = typeof v === 'object' ? (v.name || k) : v;
+ out.push({{code: k, name, group: label}});
+ }}
+ }}
+ }}
+ if (field === 'l' && f.common_codes) {{
+ for (const [k,v] of Object.entries(f.common_codes)) {{
+ out.push({{code: k, name: v}});
+ }}
+ }}
+ return out;
+}}
+
+function getGeekDomains() {{
+ const f = REG.fields.g;
+ const out = [];
+ if (f.sub_id_groups) {{
+ for (const [gk, group] of Object.entries(f.sub_id_groups)) {{
+ const label = group.label || gk;
+ for (const [k,v] of Object.entries(group.sub_ids)) {{
+ const name = typeof v === 'string' ? v : (v.name || k);
+ out.push({{code: k, name, group: label}});
+ }}
+ }}
+ }}
+ return out;
+}}
+
+/* === Encoder state === */
+const encoderState = {{}};
+
+function markIncluded(code) {{
+ const el = document.getElementById('skip-' + code);
+ if (el && !el.disabled) el.checked = true;
+}}
+
+function buildEncoder() {{
+ const container = document.getElementById('enc-fields');
+ const allCodes = Object.keys(REG.fields).sort();
+ // Put g first (required field)
+ const codes = ['g', ...allCodes.filter(c => c !== 'g')];
+
+ for (const code of codes) {{
+ const f = REG.fields[code];
+ const isRequired = code === 'g';
+ const section = document.createElement('details');
+ section.className = 'field-section';
+ if (isRequired) section.open = true;
+
+ const summary = document.createElement('summary');
+ const skipId = 'skip-' + code;
+ const reqLabel = isRequired ? ' (required)' : '';
+ summary.innerHTML = code.toUpperCase() + ' — ' + f.name + reqLabel +
+ '<label class="skip-check"><input type="checkbox" id="' + skipId + '" checked onchange="encodeUGI()"' +
+ (isRequired ? ' disabled' : '') + '> include</label>';
+ section.appendChild(summary);
+
+ const inner = document.createElement('div');
+ inner.id = 'enc-field-' + code;
+
+ if (f.type === 'direct') {{
+ buildDirectField(inner, code, f);
+ }} else if (f.type === 'multi') {{
+ buildMultiField(inner, code, f);
+ }} else if (f.type === 'single') {{
+ buildSingleField(inner, code, f);
+ }} else if (f.type === 'special') {{
+ if (code === 'g') buildGeekField(inner, f);
+ else if (code === 'b') buildBuildField(inner, f);
+ else if (code === 'v') buildPoliticsField(inner, f);
+ else if (code === 'l') buildLangField(inner, f);
+ }}
+
+ section.appendChild(inner);
+ container.appendChild(section);
+
+ // Default: unchecked (not included) except g (always checked)
+ if (code !== 'g') {{
+ document.getElementById(skipId).checked = false;
+ }}
+ }}
+}}
+
+function buildDirectField(container, code, f) {{
+ const scale = getScale(code);
+ const mods = f.modifiers || {{}};
+
+ const row = document.createElement('div');
+ row.className = 'slider-row';
+
+ const slider = document.createElement('input');
+ slider.type = 'range'; slider.min = 0; slider.max = 7; slider.value = 4;
+ slider.id = 'enc-' + code + '-val';
+ slider.oninput = function() {{
+ document.getElementById('enc-' + code + '-display').textContent = this.value;
+ const s = scale[this.value];
+ document.getElementById('enc-' + code + '-label').textContent = s ? (s.label || '') : '';
+ const h = s ? (s.humor || '') : '';
+ document.getElementById('enc-' + code + '-humor').textContent = h;
+ markIncluded(code);
+ encodeUGI();
+ }};
+ row.appendChild(slider);
+
+ const val = document.createElement('span');
+ val.className = 'slider-val'; val.id = 'enc-' + code + '-display'; val.textContent = '4';
+ row.appendChild(val);
+
+ const lab = document.createElement('span');
+ lab.className = 'slider-label'; lab.id = 'enc-' + code + '-label';
+ lab.textContent = scale['4'] ? (scale['4'].label || '') : '';
+ row.appendChild(lab);
+
+ container.appendChild(row);
+
+ const humor = document.createElement('div');
+ humor.className = 'humor'; humor.id = 'enc-' + code + '-humor';
+ humor.textContent = scale['4'] ? (scale['4'].humor || '') : '';
+ container.appendChild(humor);
+
+ if (mods.alternative) {{
+ const altDiv = document.createElement('div');
+ altDiv.innerHTML = '<label><input type="checkbox" id="enc-' + code + '-alt-on" onchange="encodeUGI()"> Alternative value</label>';
+ const altSlider = document.createElement('input');
+ altSlider.type = 'range'; altSlider.min = 0; altSlider.max = 7; altSlider.value = 4;
+ altSlider.id = 'enc-' + code + '-alt'; altSlider.oninput = function() {{ encodeUGI(); }};
+ altDiv.appendChild(altSlider);
+ container.appendChild(altDiv);
+ }}
+
+ if (mods.paid) {{
+ const pDiv = document.createElement('div');
+ pDiv.innerHTML = '<label><input type="checkbox" id="enc-' + code + '-paid" onchange="encodeUGI()"> Paid ($)</label>';
+ container.appendChild(pDiv);
+ }}
+
+ if (mods.aspire) {{
+ const aDiv = document.createElement('div');
+ aDiv.innerHTML = '<label><input type="checkbox" id="enc-' + code + '-aspire-on" onchange="encodeUGI()"> Aspiring to:</label>';
+ const aSel = document.createElement('select');
+ aSel.id = 'enc-' + code + '-aspire';
+ for (let i = 5; i <= 7; i++) {{
+ const opt = document.createElement('option');
+ opt.value = i; opt.textContent = i + ' — ' + (scale[i] ? (scale[i].label || '') : '');
+ aSel.appendChild(opt);
+ }}
+ aSel.onchange = function() {{ encodeUGI(); }};
+ aDiv.appendChild(aSel);
+ container.appendChild(aDiv);
+ }}
+}}
+
+function buildMultiField(container, code, f) {{
+ const subIds = getSubIds(code);
+ const mods = f.modifiers || {{}};
+ const scale = getScale(code);
+
+ encoderState[code] = [];
+
+ const addRow = document.createElement('div');
+ addRow.className = 'multi-add';
+
+ const sel = document.createElement('select');
+ sel.id = 'enc-' + code + '-sel';
+ const defOpt = document.createElement('option');
+ defOpt.value = ''; defOpt.textContent = '— select —';
+ sel.appendChild(defOpt);
+
+ let lastGroup = '';
+ for (const s of subIds) {{
+ if (s.group && s.group !== lastGroup) {{
+ const optg = document.createElement('optgroup');
+ optg.label = s.group;
+ sel.appendChild(optg);
+ lastGroup = s.group;
+ }}
+ const opt = document.createElement('option');
+ opt.value = s.code; opt.textContent = s.code + ' — ' + s.name;
+ if (lastGroup) {{
+ sel.lastElementChild.appendChild(opt);
+ }} else {{
+ sel.appendChild(opt);
+ }}
+ }}
+ addRow.appendChild(sel);
+
+ const rSlider = document.createElement('input');
+ rSlider.type = 'range'; rSlider.min = 0; rSlider.max = 7; rSlider.value = 5;
+ rSlider.id = 'enc-' + code + '-rating';
+ addRow.appendChild(rSlider);
+
+ const rVal = document.createElement('span');
+ rVal.className = 'slider-val';
+ rVal.id = 'enc-' + code + '-rating-display';
+ rVal.textContent = '5';
+ addRow.appendChild(rVal);
+ const rLabel = document.createElement('span');
+ rLabel.className = 'slider-label';
+ rLabel.id = 'enc-' + code + '-rating-label';
+ rLabel.textContent = scale['5'] ? (scale['5'].label || '') : '';
+ rSlider.oninput = function() {{
+ rVal.textContent = this.value;
+ const s = scale[this.value];
+ rLabel.textContent = s ? (s.label || '') : '';
+ }};
+ addRow.appendChild(rLabel);
+
+ if (mods.paid) {{
+ const pCb = document.createElement('label');
+ pCb.innerHTML = '<input type="checkbox" id="enc-' + code + '-add-paid"> $';
+ addRow.appendChild(pCb);
+ }}
+ if (mods.aspire) {{
+ const aCb = document.createElement('label');
+ aCb.innerHTML = '<input type="checkbox" id="enc-' + code + '-add-aspire-on"> +';
+ addRow.appendChild(aCb);
+ const aSel = document.createElement('select');
+ aSel.id = 'enc-' + code + '-add-aspire';
+ for (let i = 5; i <= 7; i++) {{
+ const opt = document.createElement('option');
+ opt.value = i; opt.textContent = i + ' — ' + (scale[i] ? (scale[i].label || '') : '');
+ aSel.appendChild(opt);
+ }}
+ addRow.appendChild(aSel);
+ }}
+
+ const addBtn = document.createElement('button');
+ addBtn.textContent = 'Add';
+ addBtn.onclick = function() {{
+ const subId = sel.value;
+ if (!subId) return;
+ const rating = rSlider.value;
+ const entry = {{ sub: subId, rating: parseInt(rating) }};
+ if (mods.paid) {{
+ const pEl = document.getElementById('enc-' + code + '-add-paid');
+ if (pEl && pEl.checked) entry.paid = true;
+ }}
+ if (mods.aspire) {{
+ const aOn = document.getElementById('enc-' + code + '-add-aspire-on');
+ const aVal = document.getElementById('enc-' + code + '-add-aspire');
+ if (aOn && aOn.checked) entry.aspire = parseInt(aVal.value);
+ }}
+ encoderState[code].push(entry);
+ renderChips(code);
+ markIncluded(code);
+ encodeUGI();
+ }};
+ addRow.appendChild(addBtn);
+ container.appendChild(addRow);
+
+ const chipBox = document.createElement('div');
+ chipBox.id = 'enc-' + code + '-chips';
+ container.appendChild(chipBox);
+}}
+
+function renderChips(code) {{
+ const box = document.getElementById('enc-' + code + '-chips');
+ box.innerHTML = '';
+ for (let i = 0; i < encoderState[code].length; i++) {{
+ const e = encoderState[code][i];
+ const chip = document.createElement('span');
+ chip.className = 'chip';
+ let txt = e.sub + e.rating;
+ if (e.paid) txt += '$';
+ if (e.aspire) txt += '+' + e.aspire;
+ chip.innerHTML = txt + ' <span class="remove" onclick="removeChip(\\''+code+'\\','+i+')">&times;</span>';
+ box.appendChild(chip);
+ }}
+}}
+
+function removeChip(code, idx) {{
+ encoderState[code].splice(idx, 1);
+ renderChips(code);
+ encodeUGI();
+}}
+
+function buildSingleField(container, code, f) {{
+ const subIds = getSubIds(code);
+ const scale = getScale(code);
+
+ const sel = document.createElement('select');
+ sel.id = 'enc-' + code + '-sub';
+ sel.onchange = function() {{ markIncluded(code); encodeUGI(); }};
+ const defOpt = document.createElement('option');
+ defOpt.value = ''; defOpt.textContent = '— select —';
+ sel.appendChild(defOpt);
+ for (const s of subIds) {{
+ const opt = document.createElement('option');
+ opt.value = s.code; opt.textContent = s.code + ' — ' + s.name;
+ sel.appendChild(opt);
+ }}
+ container.appendChild(sel);
+
+ const row = document.createElement('div');
+ row.className = 'slider-row';
+ const slider = document.createElement('input');
+ slider.type = 'range'; slider.min = 0; slider.max = 7; slider.value = 4;
+ slider.id = 'enc-' + code + '-val';
+ slider.oninput = function() {{
+ document.getElementById('enc-' + code + '-display').textContent = this.value;
+ const s = scale[this.value];
+ document.getElementById('enc-' + code + '-label').textContent = s ? (s.label || '') : '';
+ markIncluded(code);
+ encodeUGI();
+ }};
+ row.appendChild(slider);
+ const val = document.createElement('span');
+ val.className = 'slider-val'; val.id = 'enc-' + code + '-display'; val.textContent = '4';
+ row.appendChild(val);
+ const lab = document.createElement('span');
+ lab.className = 'slider-label'; lab.id = 'enc-' + code + '-label';
+ lab.textContent = scale['4'] ? (scale['4'].label || '') : '';
+ row.appendChild(lab);
+ container.appendChild(row);
+}}
+
+function buildGeekField(container, f) {{
+ const domains = getGeekDomains();
+ encoderState.g = {{ domains: [], custom: '' }};
+
+ let lastGroup = '';
+ let gridDiv = null;
+ for (const d of domains) {{
+ if (d.group && d.group !== lastGroup) {{
+ const h = document.createElement('h3');
+ h.textContent = d.group;
+ container.appendChild(h);
+ lastGroup = d.group;
+ gridDiv = document.createElement('div');
+ gridDiv.style.display = 'grid';
+ gridDiv.style.gridTemplateColumns = 'repeat(auto-fill, minmax(220px, 1fr))';
+ gridDiv.style.gap = '0.1rem 0';
+ container.appendChild(gridDiv);
+ }}
+ const lbl = document.createElement('label');
+ lbl.innerHTML = '<input type="checkbox" data-gcode="' + d.code + '" onchange="updateGeek()"> ' +
+ d.code + ' ' + d.name +
+ ' <input type="checkbox" data-gpaid="' + d.code + '" onchange="updateGeek()" title="paid ($)" style="margin-left:4px"> $';
+ (gridDiv || container).appendChild(lbl);
+ }}
+
+ const customDiv = document.createElement('div');
+ customDiv.style.marginTop = '0.5rem';
+ customDiv.innerHTML = '<label>Custom (~): <input type="text" id="enc-g-custom" placeholder="fermenting" oninput="updateGeek()"></label>';
+ container.appendChild(customDiv);
+}}
+
+function updateGeek() {{
+ const domains = [];
+ document.querySelectorAll('[data-gcode]').forEach(cb => {{
+ if (cb.checked) {{
+ const code = cb.getAttribute('data-gcode');
+ const paidCb = document.querySelector('[data-gpaid="' + code + '"]');
+ domains.push({{ code, paid: paidCb && paidCb.checked }});
+ }}
+ }});
+ encoderState.g = {{
+ domains,
+ custom: (document.getElementById('enc-g-custom').value || '').trim()
+ }};
+ encodeUGI();
+}}
+
+function buildBuildField(container, f) {{
+ for (const dim of ['height', 'width']) {{
+ const vals = f.dimensions[dim].values;
+ const row = document.createElement('div');
+ row.className = 'slider-row';
+ row.innerHTML = '<strong>' + dim + ':</strong>';
+ const slider = document.createElement('input');
+ slider.type = 'range'; slider.min = 0; slider.max = 7; slider.value = 4;
+ slider.id = 'enc-b-' + dim;
+ const disp = document.createElement('span');
+ disp.className = 'slider-val'; disp.id = 'enc-b-' + dim + '-display'; disp.textContent = '4';
+ const lab = document.createElement('span');
+ lab.className = 'slider-label'; lab.id = 'enc-b-' + dim + '-label';
+ lab.textContent = vals['4'] || '';
+ slider.oninput = function() {{
+ disp.textContent = this.value;
+ lab.textContent = vals[this.value] || '';
+ markIncluded('b');
+ encodeUGI();
+ }};
+ row.appendChild(slider); row.appendChild(disp); row.appendChild(lab);
+ container.appendChild(row);
+ }}
+
+ const aDiv = document.createElement('div');
+ aDiv.innerHTML = '<label><input type="checkbox" id="enc-b-aspire-on" onchange="encodeUGI()"> Aspiring (+)</label>';
+ container.appendChild(aDiv);
+}}
+
+function buildPoliticsField(container, f) {{
+ for (const dim of ['social', 'economic']) {{
+ const vals = f.dimensions[dim].values;
+ const row = document.createElement('div');
+ row.className = 'slider-row';
+ row.innerHTML = '<strong>' + dim + ':</strong>';
+ const slider = document.createElement('input');
+ slider.type = 'range'; slider.min = 0; slider.max = 7; slider.value = 4;
+ slider.id = 'enc-v-' + dim;
+ const disp = document.createElement('span');
+ disp.className = 'slider-val'; disp.id = 'enc-v-' + dim + '-display'; disp.textContent = '4';
+ const lab = document.createElement('span');
+ lab.className = 'slider-label'; lab.id = 'enc-v-' + dim + '-label';
+ lab.textContent = vals['4'] || '';
+ slider.oninput = function() {{
+ disp.textContent = this.value;
+ lab.textContent = vals[this.value] || '';
+ markIncluded('v');
+ encodeUGI();
+ }};
+ row.appendChild(slider); row.appendChild(disp); row.appendChild(lab);
+ container.appendChild(row);
+ }}
+}}
+
+function buildLangField(container, f) {{
+ encoderState.l = [];
+ const mods = f.modifiers || {{}};
+ const scale = getScale('l');
+
+ const addRow = document.createElement('div');
+ addRow.className = 'multi-add';
+
+ const codes = f.common_codes || {{}};
+ const sel = document.createElement('select');
+ sel.id = 'enc-l-sel';
+ const defOpt = document.createElement('option');
+ defOpt.value = ''; defOpt.textContent = '— select or type —';
+ sel.appendChild(defOpt);
+ for (const [k,v] of Object.entries(codes)) {{
+ const opt = document.createElement('option');
+ opt.value = k; opt.textContent = k + ' — ' + v;
+ sel.appendChild(opt);
+ }}
+ addRow.appendChild(sel);
+
+ const customInput = document.createElement('input');
+ customInput.type = 'text'; customInput.size = 4; customInput.maxLength = 2;
+ customInput.placeholder = 'or ISO'; customInput.id = 'enc-l-custom';
+ addRow.appendChild(customInput);
+
+ const rSlider = document.createElement('input');
+ rSlider.type = 'range'; rSlider.min = 0; rSlider.max = 7; rSlider.value = 5;
+ rSlider.id = 'enc-l-rating';
+ addRow.appendChild(rSlider);
+ const rVal = document.createElement('span');
+ rVal.className = 'slider-val'; rVal.id = 'enc-l-rating-display'; rVal.textContent = '5';
+ addRow.appendChild(rVal);
+ const rLabel = document.createElement('span');
+ rLabel.className = 'slider-label'; rLabel.id = 'enc-l-rating-label';
+ rLabel.textContent = scale['5'] ? (scale['5'].label || '') : '';
+ rSlider.oninput = function() {{
+ rVal.textContent = this.value;
+ const s = scale[this.value];
+ rLabel.textContent = s ? (s.label || '') : '';
+ }};
+ addRow.appendChild(rLabel);
+
+ if (mods.paid) {{
+ const pCb = document.createElement('label');
+ pCb.innerHTML = '<input type="checkbox" id="enc-l-add-paid"> $';
+ addRow.appendChild(pCb);
+ }}
+ if (mods.aspire) {{
+ const aCb = document.createElement('label');
+ aCb.innerHTML = '<input type="checkbox" id="enc-l-add-aspire-on"> +';
+ addRow.appendChild(aCb);
+ const aSel = document.createElement('select');
+ aSel.id = 'enc-l-add-aspire';
+ for (let i = 5; i <= 7; i++) {{
+ const opt = document.createElement('option');
+ opt.value = i; opt.textContent = i + ' — ' + (scale[i] ? (scale[i].label || '') : '');
+ aSel.appendChild(opt);
+ }}
+ addRow.appendChild(aSel);
+ }}
+
+ const addBtn = document.createElement('button');
+ addBtn.textContent = 'Add';
+ addBtn.onclick = function() {{
+ let langCode = sel.value || customInput.value.toLowerCase().trim();
+ if (!langCode || langCode.length !== 2) return;
+ const rating = document.getElementById('enc-l-rating').value;
+ const entry = {{ sub: langCode, rating: parseInt(rating) }};
+ if (mods.paid) {{
+ const pEl = document.getElementById('enc-l-add-paid');
+ if (pEl && pEl.checked) entry.paid = true;
+ }}
+ if (mods.aspire) {{
+ const aOn = document.getElementById('enc-l-add-aspire-on');
+ const aVal = document.getElementById('enc-l-add-aspire');
+ if (aOn && aOn.checked) entry.aspire = parseInt(aVal.value);
+ }}
+ encoderState.l.push(entry);
+ renderChips('l');
+ markIncluded('l');
+ encodeUGI();
+ }};
+ addRow.appendChild(addBtn);
+ container.appendChild(addRow);
+
+ const chipBox = document.createElement('div');
+ chipBox.id = 'enc-l-chips';
+ container.appendChild(chipBox);
+}}
+
+/* === Encode === */
+function encodeUGI() {{
+ const handle = document.getElementById('enc-handle').value.trim();
+ const version = document.getElementById('enc-version').value.trim() || '0';
+ if (!handle) {{
+ document.getElementById('enc-uri-output').textContent = '(enter a handle)';
+ document.getElementById('enc-block-output').textContent = '';
+ return;
+ }}
+
+ const parts = [];
+ const blockParts = [];
+
+ const codes = Object.keys(REG.fields).sort();
+ for (const code of codes) {{
+ const skipEl = document.getElementById('skip-' + code);
+ if (skipEl && !skipEl.checked) continue;
+
+ const f = REG.fields[code];
+ const ftype = f.type;
+
+ if (ftype === 'direct') {{
+ const valEl = document.getElementById('enc-' + code + '-val');
+ if (!valEl) continue;
+ let s = code + valEl.value;
+ let bs = code.toUpperCase() + valEl.value;
+ const mods = f.modifiers || {{}};
+
+ if (mods.alternative) {{
+ const altOn = document.getElementById('enc-' + code + '-alt-on');
+ if (altOn && altOn.checked) {{
+ const altVal = document.getElementById('enc-' + code + '-alt');
+ s += '/' + altVal.value;
+ bs += '/' + altVal.value;
+ }}
+ }}
+ if (mods.paid) {{
+ const pEl = document.getElementById('enc-' + code + '-paid');
+ if (pEl && pEl.checked) {{ s += '$'; bs += '$'; }}
+ }}
+ if (mods.aspire) {{
+ const aOn = document.getElementById('enc-' + code + '-aspire-on');
+ if (aOn && aOn.checked) {{
+ const aVal = document.getElementById('enc-' + code + '-aspire').value;
+ s += '+' + aVal; bs += '+' + aVal;
+ }}
+ }}
+ parts.push(s);
+ blockParts.push(bs);
+
+ }} else if (ftype === 'multi') {{
+ const entries = encoderState[code] || [];
+ if (entries.length === 0) continue;
+ let uri = code;
+ const bps = [];
+ for (const e of entries) {{
+ let frag = e.sub + e.rating;
+ if (e.paid) frag += '$';
+ if (e.aspire) frag += '+' + e.aspire;
+ uri += frag;
+ bps.push(code.toUpperCase() + frag);
+ }}
+ parts.push(uri);
+ blockParts.push(...bps);
+
+ }} else if (ftype === 'single') {{
+ const subEl = document.getElementById('enc-' + code + '-sub');
+ const valEl = document.getElementById('enc-' + code + '-val');
+ if (!subEl || !subEl.value) continue;
+ const s = code + subEl.value + valEl.value;
+ const bs = code.toUpperCase() + subEl.value + valEl.value;
+ parts.push(s);
+ blockParts.push(bs);
+
+ }} else if (ftype === 'special') {{
+ if (code === 'g') {{
+ const gState = encoderState.g || {{ domains: [], custom: '' }};
+ if (gState.domains.length === 0 && !gState.custom) continue;
+ const dParts = gState.domains.map(d => d.code + (d.paid ? '$' : ''));
+ if (gState.custom) dParts.push('~' + gState.custom);
+ const gStr = 'g' + dParts.join('/');
+ parts.push(gStr);
+ blockParts.push('G' + dParts.join('/'));
+
+ }} else if (code === 'b') {{
+ const h = document.getElementById('enc-b-height');
+ const w = document.getElementById('enc-b-width');
+ if (!h || !w) continue;
+ parts.push('b' + h.value + '/' + w.value);
+ blockParts.push('B' + h.value + '/' + w.value);
+
+ }} else if (code === 'v') {{
+ const s = document.getElementById('enc-v-social');
+ const e = document.getElementById('enc-v-economic');
+ if (!s || !e) continue;
+ parts.push('v' + s.value + '/' + e.value);
+ blockParts.push('V' + s.value + '/' + e.value);
+
+ }} else if (code === 'l') {{
+ const entries = encoderState.l || [];
+ if (entries.length === 0) continue;
+ let uri = 'l';
+ const bps = [];
+ for (const e of entries) {{
+ let frag = e.sub + e.rating;
+ if (e.paid) frag += '$';
+ if (e.aspire) frag += '+' + e.aspire;
+ uri += frag;
+ bps.push('L' + frag);
+ }}
+ parts.push(uri);
+ blockParts.push(...bps);
+ }}
+ }}
+ }}
+
+ // Build URI
+ const uri = 'ugi:' + version + '@' + handle + ':' + parts.join(',');
+ document.getElementById('enc-uri-output').textContent = uri;
+
+ // Build block
+ // G field goes on first line
+ const gPart = blockParts.find(p => p.startsWith('G'));
+ const otherParts = blockParts.filter(p => !p.startsWith('G'));
+
+ let block = '------- BEGIN UGI BLOCK -------\\n';
+ block += 'v:' + version + ' @' + handle;
+ if (gPart) block += ' ' + gPart;
+ block += '\\n';
+
+ // Group by category
+ const cats = {{}};
+ for (const bp of otherParts) {{
+ const c = bp[0].toLowerCase();
+ const fld = REG.fields[c];
+ const cat = fld ? fld.category : 'other';
+ if (!cats[cat]) cats[cat] = [];
+ cats[cat].push(bp);
+ }}
+
+ const catOrder = ['identity', 'appearance', 'tech', 'stance', 'entertainment', 'lifestyle'];
+ for (const cat of catOrder) {{
+ if (cats[cat] && cats[cat].length > 0) {{
+ block += cats[cat].join(' ') + '\\n';
+ }}
+ }}
+
+ block += '-------- END UGI BLOCK --------';
+ document.getElementById('enc-block-output').textContent = block;
+}}
+
+/* === Decoder === */
+function decodeUGI() {{
+ const input = document.getElementById('dec-input').value.trim();
+ const output = document.getElementById('dec-output');
+ if (!input) {{ output.innerHTML = ''; return; }}
+
+ try {{
+ let uri = input;
+ // Detect block format
+ if (input.includes('BEGIN UGI BLOCK')) {{
+ uri = blockToUri(input);
+ }}
+ const parsed = parseUri(uri);
+ renderDecoded(parsed, output);
+ }} catch(e) {{
+ output.innerHTML = '<p style="color:red">Parse error: ' + e.message + '</p>';
+ }}
+}}
+
+function parseUri(uri) {{
+ let rest = uri;
+ if (rest.toLowerCase().startsWith('ugi:')) rest = rest.substring(4);
+
+ const atIdx = rest.indexOf('@');
+ if (atIdx < 0) throw new Error('Missing @handle');
+ const version = rest.substring(0, atIdx);
+ rest = rest.substring(atIdx + 1);
+
+ const colonIdx = rest.indexOf(':');
+ if (colonIdx < 0) throw new Error('Missing : after handle');
+ const handle = rest.substring(0, colonIdx);
+ rest = rest.substring(colonIdx + 1);
+
+ const fieldStrs = rest.split(',');
+ const result = {{ version, handle, fields: [] }};
+
+ for (const fs of fieldStrs) {{
+ if (!fs) continue;
+ const code = fs[0].toLowerCase();
+ const raw = fs.substring(1);
+ const f = REG.fields[code];
+ if (!f) {{
+ result.fields.push({{ code, raw, name: '(unknown)', decoded: raw }});
+ continue;
+ }}
+
+ result.fields.push({{ code, raw, name: f.name, decoded: decodeField(code, raw, f) }});
+ }}
+
+ return result;
+}}
+
+function decodeField(code, raw, f) {{
+ const ftype = f.type;
+ const scale = getScale(code);
+
+ if (ftype === 'direct') {{
+ if (!raw) return '';
+ const digit = raw[0];
+ const label = scale[digit] ? (scale[digit].label || digit) : digit;
+ let result = label;
+ let rest = raw.substring(1);
+ if (rest.includes('/')) {{
+ const parts = rest.split('/');
+ if (parts[1]) {{
+ const altLabel = scale[parts[1][0]] ? (scale[parts[1][0]].label || parts[1][0]) : parts[1][0];
+ result += ' / ' + altLabel;
+ rest = parts[1].substring(1);
+ }}
+ }} else {{
+ // modifiers
+ rest = raw.substring(1);
+ }}
+ if (rest.includes('$')) result += ' [paid]';
+ const aspMatch = rest.match(/\\+(\\d)/);
+ if (aspMatch) result += ' [aspiring to ' + aspMatch[1] + ']';
+ return result;
+
+ }} else if (ftype === 'special') {{
+ if (code === 'g') {{
+ return raw.split('/').map(d => {{
+ if (d.startsWith('~')) return 'custom:' + d.substring(1);
+ const paid = d.endsWith('$');
+ const dcode = paid ? d.slice(0,-1) : d;
+ const name = resolveGeekDomain(dcode);
+ return name + (paid ? ' [paid]' : '');
+ }}).join(', ');
+ }} else if (code === 'b' || code === 'v') {{
+ const parts = raw.split('/');
+ const dims = Object.keys(f.dimensions);
+ return dims.map((d, i) => {{
+ const v = parts[i] ? parts[i][0] : '?';
+ const vals = f.dimensions[d].values;
+ return d + ': ' + (vals[v] || v);
+ }}).join(', ');
+ }}
+ return raw;
+
+ }} else if (ftype === 'multi' || ftype === 'single') {{
+ // l uses ISO 639-1 codes with common_codes lookup, not sub_ids
+ if (code === 'l') return decodeLangEntries(raw, f);
+ return decodeMultiEntries(code, raw, f, scale);
+ }}
+ return raw;
+}}
+
+function decodeMultiEntries(code, raw, f, scale) {{
+ const entries = [];
+ let i = 0;
+ while (i < raw.length) {{
+ if (raw[i] === '~') {{
+ let j = i + 1;
+ while (j < raw.length && /[a-zA-Z]/.test(raw[j])) j++;
+ const sub = raw.substring(i, j);
+ const digit = j < raw.length && /[0-7]/.test(raw[j]) ? raw[j] : '?';
+ let mod = '';
+ let k = j + 1;
+ while (k < raw.length && /[\\$\\+0-9]/.test(raw[k])) {{ mod += raw[k]; k++; }}
+ const label = scale[digit] ? (scale[digit].label || digit) : digit;
+ entries.push('custom:' + sub.substring(1) + '=' + label + modStr(mod));
+ i = k;
+ }} else if (/[a-zA-Z]/.test(raw[i])) {{
+ let j = i;
+ while (j < raw.length && /[a-zA-Z]/.test(raw[j])) j++;
+ const sub = raw.substring(i, j);
+ const digit = j < raw.length && /[0-7]/.test(raw[j]) ? raw[j] : '?';
+ let mod = '';
+ let k = j + 1;
+ while (k < raw.length && /[\\$\\+0-9]/.test(raw[k])) {{ mod += raw[k]; k++; }}
+ const name = resolveSubId(code, sub, f);
+ const label = scale[digit] ? (scale[digit].label || digit) : digit;
+ entries.push(name + '=' + label + modStr(mod));
+ i = k;
+ }} else {{
+ i++;
+ }}
+ }}
+ return entries.join(', ');
+}}
+
+function decodeLangEntries(raw, f) {{
+ const entries = [];
+ const scale = getScale('l');
+ let i = 0;
+ while (i < raw.length) {{
+ if (/[a-zA-Z]/.test(raw[i]) && i + 2 < raw.length) {{
+ const lang = raw.substring(i, i+2);
+ const digit = raw[i+2];
+ let mod = '';
+ let k = i + 3;
+ while (k < raw.length && /[\\$\\+0-9]/.test(raw[k])) {{ mod += raw[k]; k++; }}
+ const langName = (REG.fields.l.common_codes || {{}})[lang] || lang;
+ const label = scale[digit] ? (scale[digit].label || digit) : digit;
+ entries.push(langName + '=' + label + modStr(mod));
+ i = k;
+ }} else {{
+ i++;
+ }}
+ }}
+ return entries.join(', ');
+}}
+
+function modStr(mod) {{
+ let s = '';
+ if (mod.includes('$')) s += ' [paid]';
+ const m = mod.match(/\\+(\\d)/);
+ if (m) s += ' [aspiring to ' + m[1] + ']';
+ return s;
+}}
+
+function resolveSubId(code, sub, f) {{
+ sub = sub.toLowerCase();
+ if (f.sub_ids && f.sub_ids[sub]) {{
+ const v = f.sub_ids[sub];
+ return typeof v === 'object' ? (v.name || sub) : v;
+ }}
+ if (f.sub_id_groups) {{
+ for (const group of Object.values(f.sub_id_groups)) {{
+ if (group.sub_ids && group.sub_ids[sub]) {{
+ const v = group.sub_ids[sub];
+ return typeof v === 'object' ? (v.name || sub) : v;
+ }}
+ }}
+ }}
+ return sub;
+}}
+
+function resolveGeekDomain(code) {{
+ code = code.toLowerCase();
+ const f = REG.fields.g;
+ if (f.sub_id_groups) {{
+ for (const group of Object.values(f.sub_id_groups)) {{
+ if (group.sub_ids && group.sub_ids[code]) {{
+ const v = group.sub_ids[code];
+ return typeof v === 'string' ? v : (v.name || code);
+ }}
+ }}
+ }}
+ return code;
+}}
+
+function renderDecoded(parsed, container) {{
+ let html = '<h3>Decoded: @' + parsed.handle + ' (v' + parsed.version + ')</h3>';
+ html += '<table class="decode-table"><tr><th>Code</th><th>Field</th><th>Raw</th><th>Decoded</th></tr>';
+ for (const f of parsed.fields) {{
+ html += '<tr><td>' + f.code + '</td><td>' + f.name + '</td><td><code>' + f.raw + '</code></td><td>' + f.decoded + '</td></tr>';
+ }}
+ html += '</table>';
+ container.innerHTML = html;
+}}
+
+/* === Converter === */
+function convertUGI() {{
+ const input = document.getElementById('conv-input').value.trim();
+ const output = document.getElementById('conv-output');
+ if (!input) {{ output.textContent = ''; return; }}
+
+ try {{
+ if (input.includes('BEGIN UGI BLOCK')) {{
+ output.textContent = blockToUri(input);
+ }} else if (input.toLowerCase().startsWith('ugi:')) {{
+ output.textContent = uriToBlock(input);
+ }} else {{
+ output.textContent = '(could not detect format — paste a URI starting with "ugi:" or a block)';
+ }}
+ }} catch(e) {{
+ output.textContent = 'Error: ' + e.message;
+ }}
+}}
+
+function blockToUri(block) {{
+ const lines = block.split(/\\r?\\n/).map(l => l.trim()).filter(l => l && !l.startsWith('---'));
+ if (lines.length === 0) throw new Error('Empty block');
+
+ // First line: v:0 @handle G...
+ const firstLine = lines[0];
+ const vMatch = firstLine.match(/^v:(\\S+)\\s+@(\\S+)(?:\\s+(.*))?$/i);
+ if (!vMatch) throw new Error('Invalid first line');
+ const version = vMatch[1];
+ const handle = vMatch[2];
+ const gPart = vMatch[3] || '';
+
+ // Remaining lines: space-separated block fields
+ const blockFields = [];
+ if (gPart) blockFields.push(gPart);
+ for (let i = 1; i < lines.length; i++) {{
+ blockFields.push(...lines[i].split(/\\s+/));
+ }}
+
+ // Group by field code (first letter), merge multi entries
+ const fieldMap = {{}};
+ for (const bf of blockFields) {{
+ if (!bf) continue;
+ const code = bf[0].toLowerCase();
+ const rest = bf.substring(1);
+ if (!fieldMap[code]) fieldMap[code] = '';
+ fieldMap[code] += rest;
+ }}
+
+ const parts = [];
+ // Sort by field code
+ for (const code of Object.keys(fieldMap).sort()) {{
+ parts.push(code + fieldMap[code].toLowerCase());
+ }}
+
+ return 'ugi:' + version + '@' + handle + ':' + parts.join(',');
+}}
+
+function uriToBlock(uri) {{
+ let rest = uri;
+ if (rest.toLowerCase().startsWith('ugi:')) rest = rest.substring(4);
+ const atIdx = rest.indexOf('@');
+ const version = rest.substring(0, atIdx);
+ rest = rest.substring(atIdx + 1);
+ const colonIdx = rest.indexOf(':');
+ const handle = rest.substring(0, colonIdx);
+ rest = rest.substring(colonIdx + 1);
+
+ const fieldStrs = rest.split(',');
+ let block = '------- BEGIN UGI BLOCK -------\\n';
+ let firstLine = 'v:' + version + ' @' + handle;
+
+ const blockParts = [];
+ for (const fs of fieldStrs) {{
+ if (!fs) continue;
+ const code = fs[0].toLowerCase();
+ const raw = fs.substring(1);
+ const f = REG.fields[code];
+ if (!f) {{
+ blockParts.push({{ code, parts: [code.toUpperCase() + raw] }});
+ continue;
+ }}
+
+ if (code === 'g') {{
+ firstLine += ' G' + raw;
+ continue;
+ }}
+
+ const ftype = f.type;
+ if (ftype === 'multi' || (ftype === 'special' && code === 'l')) {{
+ // Expand: split sub-id entries
+ const expanded = expandMultiToBlock(code, raw);
+ blockParts.push({{ code, parts: expanded, cat: f.category }});
+ }} else {{
+ blockParts.push({{ code, parts: [code.toUpperCase() + raw], cat: f.category }});
+ }}
+ }}
+
+ block += firstLine + '\\n';
+
+ // Group by category
+ const cats = {{}};
+ for (const bp of blockParts) {{
+ const cat = bp.cat || 'other';
+ if (!cats[cat]) cats[cat] = [];
+ cats[cat].push(...bp.parts);
+ }}
+
+ const catOrder = ['identity', 'appearance', 'tech', 'stance', 'entertainment', 'lifestyle'];
+ for (const cat of catOrder) {{
+ if (cats[cat] && cats[cat].length > 0) {{
+ block += cats[cat].join(' ') + '\\n';
+ }}
+ }}
+
+ block += '-------- END UGI BLOCK --------';
+ return block;
+}}
+
+function expandMultiToBlock(code, raw) {{
+ const parts = [];
+ const uc = code.toUpperCase();
+ let i = 0;
+ while (i < raw.length) {{
+ if (raw[i] === '~') {{
+ let j = i + 1;
+ while (j < raw.length && /[a-zA-Z]/.test(raw[j])) j++;
+ let sub = raw.substring(i, j);
+ let digit = '';
+ if (j < raw.length && /[0-7]/.test(raw[j])) {{ digit = raw[j]; j++; }}
+ let mod = '';
+ while (j < raw.length && /[\\$\\+0-9]/.test(raw[j])) {{ mod += raw[j]; j++; }}
+ parts.push(uc + sub + digit + mod);
+ i = j;
+ }} else if (/[a-zA-Z]/.test(raw[i])) {{
+ let j = i;
+ while (j < raw.length && /[a-zA-Z]/.test(raw[j])) j++;
+ let sub = raw.substring(i, j);
+ let digit = '';
+ if (j < raw.length && /[0-7]/.test(raw[j])) {{ digit = raw[j]; j++; }}
+ let mod = '';
+ while (j < raw.length && /[\\$\\+0-9]/.test(raw[j])) {{ mod += raw[j]; j++; }}
+ parts.push(uc + sub + digit + mod);
+ i = j;
+ }} else {{
+ i++;
+ }}
+ }}
+ return parts;
+}}
+
+/* === Quick Reference === */
+function buildQuickRef() {{
+ const fields = REG.fields;
+ const codes = Object.keys(fields).sort();
+ let ref = 'FORMAT: ugi:<ver>[/<rev>]@<handle>:<fields>\\n';
+ ref += 'SCALE: 0=hostile 1=dislike 2=meh 3=slight- 4=neutral 5=like 6=strong 7=obsessed\\n';
+ ref += 'MODIFY: $ = paid + = aspire / = fluctuate,separate ~ = custom\\n\\n';
+
+ const allCodes = [];
+ for (const c of 'abcdefghijklmnopqrstuvwxyz') {{
+ if (fields[c]) {{
+ allCodes.push(c + ' ' + fields[c].name.toLowerCase());
+ }} else if (c === 'n' || c === 'u') {{
+ allCodes.push('[' + c + ' reserved]');
+ }}
+ }}
+
+ for (let i = 0; i < allCodes.length; i += 5) {{
+ ref += allCodes.slice(i, i+5).map(s => s.padEnd(16)).join('') + '\\n';
+ }}
+
+ document.getElementById('quick-ref').textContent = ref;
+}}
+
+/* === Spec tab === */
+function buildSpec() {{
+ const el = document.getElementById('spec-content');
+ let out = '';
+
+ // Global scale table
+ out += '<h2>Global Scale (0–7)</h2>';
+ out += '<table><thead><tr><th>#</th><th>General</th><th>Proficiency</th><th>Enthusiasm</th><th>Stance</th></tr></thead><tbody>';
+ for (const sv of REG.scale.values) {{
+ out += '<tr><td>' + sv.value + '</td><td>' + sv.general + '</td><td>' + sv.proficiency + '</td><td>' + sv.enthusiasm + '</td><td>' + sv.stance + '</td></tr>';
+ }}
+ out += '</tbody></table>';
+
+ // Fields: g first, then alphabetical
+ const allCodes = Object.keys(REG.fields).sort();
+ const codes = ['g', ...allCodes.filter(c => c !== 'g')];
+
+ for (const code of codes) {{
+ const f = REG.fields[code];
+ out += '<hr><h2>' + code.toUpperCase() + ' — ' + f.name;
+ if (code === 'g') out += ' <em>(required)</em>';
+ out += '</h2>';
+ if (f.description) out += '<p>' + f.description + '</p>';
+
+ let meta = '<strong>Type:</strong> ' + f.type;
+ if (f.category) meta += ' | <strong>Category:</strong> ' + f.category;
+ if (f.scale_type) meta += ' | <strong>Scale:</strong> ' + f.scale_type;
+ const mods = Object.keys(f.modifiers || {{}});
+ if (mods.length) meta += ' | <strong>Modifiers:</strong> ' + mods.join(', ');
+ out += '<p>' + meta + '</p>';
+
+ // Custom values (direct fields)
+ if (f.values) {{
+ out += '<ol start="0">';
+ for (let i = 0; i <= 7; i++) {{
+ const v = f.values[String(i)] || {{}};
+ out += '<li><strong>' + (v.label || '—') + '</strong>';
+ if (v.humor) out += ' — <em>' + v.humor + '</em>';
+ out += '</li>';
+ }}
+ out += '</ol>';
+ }}
+
+ // Custom scale labels (multi / lang / single fields)
+ if (f.scale_labels) {{
+ out += '<ol start="0">';
+ for (let i = 0; i <= 7; i++) {{
+ const label = f.scale_labels[String(i)] || '—';
+ const humor = f.humor_scale ? f.humor_scale[String(i)] : null;
+ out += '<li><strong>' + label + '</strong>';
+ if (humor) out += ' — <em>' + humor + '</em>';
+ out += '</li>';
+ }}
+ out += '</ol>';
+ }}
+
+ // Humor scale without scale_labels (multi fields referencing global scale)
+ if (!f.scale_labels && f.humor_scale) {{
+ out += '<p><em>Notable:</em></p><ul>';
+ for (const [k, h] of Object.entries(f.humor_scale)) {{
+ out += '<li>' + k + ': ' + h + '</li>';
+ }}
+ out += '</ul>';
+ }}
+
+ // Sub-IDs (flat)
+ if (f.sub_ids) {{
+ out += '<ul>';
+ for (const [sid, sv] of Object.entries(f.sub_ids)) {{
+ out += '<li><code>' + sid + '</code> — ' + sv.name;
+ if (sv.long) out += ' (block alias: <code>' + sv.long + '</code>)';
+ out += '</li>';
+ }}
+ out += '</ul>';
+ }}
+
+ // Sub-ID groups
+ if (f.sub_id_groups) {{
+ for (const [gkey, group] of Object.entries(f.sub_id_groups)) {{
+ out += '<h3>' + (group.label || gkey) + '</h3><ul>';
+ for (const [sid, sv] of Object.entries(group.sub_ids || {{}})) {{
+ out += '<li><code>' + sid + '</code> — ' + sv.name + '</li>';
+ }}
+ out += '</ul>';
+ }}
+ }}
+
+ // Build / Politics dimensions
+ if (f.dimensions) {{
+ for (const [dimName, dim] of Object.entries(f.dimensions)) {{
+ out += '<h3>' + dimName.charAt(0).toUpperCase() + dimName.slice(1) + '</h3><ul>';
+ for (const [k, v] of Object.entries(dim.values || {{}})) {{
+ out += '<li>' + k + ': ' + v + '</li>';
+ }}
+ out += '</ul>';
+ }}
+ }}
+ }}
+
+ el.innerHTML = out;
+}}
+
+/* === Init === */
+buildEncoder();
+buildQuickRef();
+buildSpec();
+encodeUGI();
+</script>
+<footer>
+<hr>
+<a href="https://gumx.cc">gumx.cc</a> /
+<a href="https://git.gumx.cc">git</a> /
+<a href="https://mail.gumx.cc">mail</a> /
+<a href="https://irc.gumx.cc">irc</a> /
+<a href="https://vpn.gumx.cc">vpn</a> /
+<a href="https://pgp.gumx.cc">pgp</a> /
+<a href="https://wk.fo">wk.fo</a>
+</footer>
+</body>
+</html>"""
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(f"Usage: {sys.argv[0]} <registry.json> <output.html>", file=sys.stderr)
+ sys.exit(1)
+
+ registry_path, output_path = sys.argv[1], sys.argv[2]
+ reg = load_registry(registry_path)
+ html_content = generate_html(reg)
+
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
+ with open(output_path, "w") as f:
+ f.write(html_content)
+
+ print(f"Generated {output_path}")
+
+
+if __name__ == "__main__":
+ main()