From a08cc7e677322f338c0217fc4fd214c50eaaac9f Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Tue, 18 Aug 2026 21:36:45 +0200 Subject: [PATCH 1/6] feat: add project landing page A static page for GitHub Pages, served from /docs. No build step, no dependencies, 48 KB total including the icon. The hero is built from the product's own vocabulary - two backlit keycaps that alternate - rather than stock imagery, so the page works without app screenshots and does not look like every other landing page. No invented testimonials or user counts: the app shipped today and has none. Verified in a real browser at 1200px and 375px. Caught and fixed a mobile overflow where the brew command, being a flex item with the default min-width:auto, stretched the page to 503px instead of scrolling in its own box. --- docs/icon.png | Bin 0 -> 31342 bytes docs/index.html | 440 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 docs/icon.png create mode 100644 docs/index.html diff --git a/docs/icon.png b/docs/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1dfed335c773d161f49092d3c881fc041d0f8892 GIT binary patch literal 31342 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 literal 0 HcmV?d00001 diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..2370990 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,440 @@ + + + + + +InputPilot — your keyboard picks the language + + + + + + + + + + + + +
+ + +
+
+

Your keyboard picks the language.

+

InputPilot notices which keyboard you are typing on and switches the macOS input source to match. Map each keyboard once, then stop thinking about it.

+ + + +
+ brew install --cask lucagerlich/tap/inputpilot + +
+ +

Free and open source · macOS 13+ · signed and notarized by Apple

+
+ + +
+
+ +
+
+

The two-keyboard shuffle

+

A German keyboard on the desk, a US layout built into the laptop. Every time you move between them, the layout is wrong: the y and z swap, the slashes hide somewhere else, and you fix it by hand — again. macOS switches input sources per app, per document, per window. It does not switch per keyboard.

+

InputPilot does exactly that, and nothing else.

+
+ +
+

How it works

+

Three steps, once.

+
+
+

Press a key

+

Type on each keyboard so InputPilot learns it. Keyboards are recognised again after unplugging, reconnecting, or moving to another port.

+
+
+

Pick a layout

+

Assign an input source to each keyboard in Settings. Add fallbacks if you want a sensible default for anything unmapped.

+
+
+

Forget about it

+

Start typing and the layout follows the keyboard. A debounce and cooldown keep it from flapping when you switch quickly.

+
+
+
+ +
+

What it does

+

Small on purpose. It solves one problem properly instead of becoming a keyboard manager.

+
+
+

Per-keyboard mapping

+

Each keyboard gets its own input source, with per-device and global fallbacks for everything else.

+
+
+

Undo

+

Reverse the last automatic switch from the menu bar when it guesses wrong.

+
+
+

Pause

+

Silence switching for 15 or 60 minutes when it would get in the way.

+
+
+

Conflict detection

+

Notices when a mapping points at an input source you have since removed, and offers to fix it.

+
+
+

Modifiers ignored

+

A lone ⇧ or ⌘ during ⌘-Tab will not change your layout. Opt in if you want it to.

+
+
+

Automatic updates

+

Signed updates through Sparkle, verified against Apple's notarization and a cryptographic signature.

+
+
+
+ +
+

About that permission

+

InputPilot asks for Input Monitoring — the permission that lets an app see keyboard events. That is a lot to ask for, so here is precisely what it does with it.

+
+

It reads which keyboard sent a key press. It does not read the key.

+
    +
  • The HID callback takes two things from each event: the device that sent it, and whether the key was a modifier. Nothing else.
  • +
  • No key code, no character, no typed text exists anywhere in the app — not in memory, not on disk, not in the debug log.
  • +
  • The only network traffic is the update check. No telemetry, no analytics, no crash reporter.
  • +
  • The source is public and the whole app is about 4,000 lines. You can read the keyboard handling in a few minutes.
  • +
+

Read the full privacy statement →

+
+
+ +
+

Questions

+
+ Why is it not on the App Store? +

Sandboxed apps cannot read HID devices directly, and that is the only way to tell keyboards apart. So InputPilot is distributed outside the App Store, signed with a Developer ID certificate and notarized by Apple — it opens without warnings.

+
+
+ InputPilot does not appear under Input Monitoring. +

Some Macs do not populate that list automatically. Add it by hand: System Settings → Privacy & Security → Input Monitoring → + → pick InputPilot in Applications, then switch it on.

+
+
+ I granted permission but nothing happens. +

macOS applies Input Monitoring on the next launch. Quit InputPilot and open it again.

+
+
+ Does it work with Bluetooth keyboards? +

Yes. Keyboards are identified by vendor, product, transport and name, so a Bluetooth keyboard is recognised again after reconnecting.

+
+
+ What does it cost? +

Nothing. It is free and open source under the Apache 2.0 licence.

+
+
+ +
+

Stop fixing your layout by hand

+

macOS 13 or newer. Takes about a minute to set up.

+ +
+
+ +
+ +
+ + + From 8f4ee21ded3c230ad5dff2f7ee8029ab8c005016 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Tue, 18 Aug 2026 22:19:36 +0200 Subject: [PATCH 2/6] feat: rebuild landing page around ASCII keyboard hero Terminal aesthetic throughout: monospace, dark, bordered panels, a window chrome around the hero. The hero is a QWERTZ keyboard drawn in box-drawing characters, with the keys that move between the German and U.S. layouts picked out in orange - the product's premise, stated in one image. The art is generated by docs/generate-keyboard-art.py so the alignment is exact rather than hand-tuned; every row is 75 characters. Below it: an example debug log and device cards in the app's own vocabulary (vendor and product IDs, transport, layout, status), then setup, privacy, and FAQ. No fabricated metrics - the reference mockups showed CPU, memory and latency readouts that this app does not measure and would be inventing. Verified in a browser at 1280px and 375px: no page overflow, art fills 80% of the terminal on desktop and fits within it on mobile. --- docs/generate-keyboard-art.py | 38 ++ docs/index.html | 674 ++++++++++++++-------------------- 2 files changed, 322 insertions(+), 390 deletions(-) create mode 100644 docs/generate-keyboard-art.py diff --git a/docs/generate-keyboard-art.py b/docs/generate-keyboard-art.py new file mode 100644 index 0000000..dca92f0 --- /dev/null +++ b/docs/generate-keyboard-art.py @@ -0,0 +1,38 @@ +# Build a 60% keyboard from box-drawing keycaps. Each unit is 5 chars wide; +# wide keys span multiples of that. Keys touch, so junctions read as a board. +U = 5 +def key(label, units=1): + w = U * units + inner = w - 2 + top = "╭" + "─" * inner + "╮" + mid = "│" + label.center(inner) + "│" + bot = "╰" + "─" * inner + "╯" + return top, mid, bot, label + +ROWS = [ + [("`",1),("1",1),("2",1),("3",1),("4",1),("5",1),("6",1),("7",1),("8",1),("9",1),("0",1),("ß",1),("´",1),("⌫",2)], + [("⇥",2),("Q",1),("W",1),("E",1),("R",1),("T",1),("Z",1),("U",1),("I",1),("O",1),("P",1),("Ü",1),("+",1),("#",1)], + [("⇪",2),("A",1),("S",1),("D",1),("F",1),("G",1),("H",1),("J",1),("K",1),("L",1),("Ö",1),("Ä",1),("⏎",2)], + [("⇧",2),("Y",1),("X",1),("C",1),("V",1),("B",1),("N",1),("M",1),(",",1),(".",1),("-",1),("⇧",3)], + [("⌃",2),("⌥",2),("⌘",2),("",5),("⌘",2),("⌥",1),("⌃",1)], +] + +# keys whose position or legend differs between the German and US layouts +DIFF = {"Z","Y","Ü","Ö","Ä","+","ß","´","#","-"} + +lines = [] +for row in ROWS: + tops, mids, bots = "", "", "" + for label, units in row: + t, m, b, lab = key(label, units) + tops += t; bots += b + if lab in DIFF: + m = m.replace(lab, f"\x00{lab}\x01", 1) + mids += m + lines += [tops, mids, bots] + +widths = {len(l.replace('\x00','').replace('\x01','')) for l in lines} +print("row widths:", widths) +art = "\n".join(lines) +open("kbd.txt","w").write(art) +print(art.replace('\x00','').replace('\x01','')) diff --git a/docs/index.html b/docs/index.html index 2370990..700d8ea 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,438 +3,332 @@ -InputPilot — your keyboard picks the language - +InputPilot — the layout follows the keyboard + - - + + -
- - -
-
-

Your keyboard picks the language.

-

InputPilot notices which keyboard you are typing on and switches the macOS input source to match. Map each keyboard once, then stop thinking about it.

- - - -
- brew install --cask lucagerlich/tap/inputpilot - -
+ Download +
+
-

Free and open source · macOS 13+ · signed and notarized by Apple

+
+
+
+ + inputpilot — active keyboard
- - + +

The layout follows the keyboard.Not the app. Not the window.

+

macOS switches input sources per app and per window — never per keyboard. InputPilot watches which keyboard produced the last key press and switches the input source to match. Map each keyboard once, then forget it exists.

+ + +

macOS 13+ · signed & notarized · Apache-2.0 · v1.0.1 · brew install --cask lucagerlich/tap/inputpilot

-
-

The two-keyboard shuffle

-

A German keyboard on the desk, a US layout built into the laptop. Every time you move between them, the layout is wrong: the y and z swap, the slashes hide somewhere else, and you fix it by hand — again. macOS switches input sources per app, per document, per window. It does not switch per keyboard.

-

InputPilot does exactly that, and nothing else.

-
-
-

How it works

-

Three steps, once.

-
-
-

Press a key

-

Type on each keyboard so InputPilot learns it. Keyboards are recognised again after unplugging, reconnecting, or moving to another port.

-
-
-

Pick a layout

-

Assign an input source to each keyboard in Settings. Add fallbacks if you want a sensible default for anything unmapped.

-
-
-

Forget about it

-

Start typing and the layout follows the keyboard. A debounce and cooldown keep it from flapping when you switch quickly.

-
-
-
+
+

What it looks like when it works

+

Nothing. You type on the other keyboard and the language is already right. The debug log is the only place it shows its work — useful when reporting a bug, and safe to share.

-
-

What it does

-

Small on purpose. It solves one problem properly instead of becoming a keyboard manager.

-
-
-

Per-keyboard mapping

-

Each keyboard gets its own input source, with per-device and global fallbacks for everything else.

-
-
-

Undo

-

Reverse the last automatic switch from the menu bar when it guesses wrong.

-
-
-

Pause

-

Silence switching for 15 or 60 minutes when it would get in the way.

-
-
-

Conflict detection

-

Notices when a mapping points at an input source you have since removed, and offers to fix it.

-
-
-

Modifiers ignored

-

A lone ⇧ or ⌘ during ⌘-Tab will not change your layout. Opt in if you want it to.

-
-
-

Automatic updates

-

Signed updates through Sparkle, verified against Apple's notarization and a cryptographic signature.

-
+
+
+ Debug log EXAMPLE + menu bar → Open Debug +
+
+
[10:24:03.112] INFO permission    Input Monitoring granted.
+
[10:24:03.118] INFO hid-monitor   HID monitor started.
+
[10:24:07.402] INFO active-device Active device changed to Magic Keyboard (VID 1452, PID 640).
+
[10:24:07.806] SWITCH auto          Magic Keyboard → German
+
[10:31:15.220] INFO active-device Active device changed to Keychron K2 (VID 13364, PID 630).
+
[10:31:15.624] SWITCH auto          Keychron K2 → U.S.
+
[10:47:02.918] WARN conflict      Mapped source no longer enabled: com.apple.keylayout.French
+
[10:47:02.918] INFO active-device 
-
+
-
-

About that permission

-

InputPilot asks for Input Monitoring — the permission that lets an app see keyboard events. That is a lot to ask for, so here is precisely what it does with it.

-
-

It reads which keyboard sent a key press. It does not read the key.

-
    -
  • The HID callback takes two things from each event: the device that sent it, and whether the key was a modifier. Nothing else.
  • -
  • No key code, no character, no typed text exists anywhere in the app — not in memory, not on disk, not in the debug log.
  • -
  • The only network traffic is the update check. No telemetry, no analytics, no crash reporter.
  • -
  • The source is public and the whole app is about 4,000 lines. You can read the keyboard handling in a few minutes.
  • -
-

Read the full privacy statement →

+
+
+
Magic Keyboard
+
Vendor ID1452
+
Product ID640
+
TransportUSB
+
LayoutGerman
+
Status■ active
+
+
+
Keychron K2
+
Vendor ID13364
+
Product ID630
+
TransportBluetooth
+
LayoutU.S.
+
Status■ mapped
+
+
+
Built-in Keyboard
+
Vendor ID1452
+
Product ID834
+
TransportInternal
+
Layoutglobal fallback
+
Status■ unmapped
+
+
+
+ +
+

How it works

+

Three steps, once.

+
+
+
01
+

Press a key

+

Type on each keyboard so InputPilot learns it. Devices are recognised again after unplugging, reconnecting, or moving to another port.

+
+
+
02
+

Pick a layout

+

Assign an input source per keyboard in Settings. Add per-device or global fallbacks for anything unmapped.

+
+
+
03
+

Forget about it

+

A 400 ms debounce and a 1.5 s cooldown keep it from flapping. Modifier-only presses are ignored, so ⌘-Tab never changes your layout.

-
+
+ + +
+

About that permission

+

InputPilot asks for Input Monitoring — the permission that lets an app see keyboard events. That is a lot to ask for, so here is exactly what it does with it.

+
+

It reads which keyboard sent a key press. It does not read the key.

+
    +
  • The HID callback takes two things from each event: the device that sent it, and whether the key was a modifier. Nothing else.
  • +
  • No key code, no character, no typed text exists anywhere in the app — not in memory, not on disk, not in the debug log.
  • +
  • The only network traffic is the update check. No telemetry, no analytics, no crash reporter.
  • +
  • The source is public and the app is about 4,000 lines. The keyboard handling reads in a few minutes.
  • +
+

Full privacy statement →

+
+
+ +
+

Questions

+
+
Why is it not on the App Store? +

Sandboxed apps cannot read HID devices directly, and that is the only way to tell keyboards apart. So InputPilot ships outside the App Store, signed with a Developer ID certificate and notarized by Apple — it opens without warnings.

+
InputPilot does not appear under Input Monitoring. +

Some Macs do not populate that list automatically. Add it by hand: System Settings → Privacy & Security → Input Monitoring → + → pick InputPilot in Applications, then switch it on.

+
I granted permission but nothing happens. +

macOS applies Input Monitoring on the next launch. Quit InputPilot and open it again.

+
Does it work with Bluetooth keyboards? +

Yes. Keyboards are identified by vendor, product, transport and name, so a Bluetooth keyboard is recognised again after reconnecting.

+
What does it cost? +

Nothing. Free and open source under the Apache 2.0 licence.

+
+
-
-

Questions

-
- Why is it not on the App Store? -

Sandboxed apps cannot read HID devices directly, and that is the only way to tell keyboards apart. So InputPilot is distributed outside the App Store, signed with a Developer ID certificate and notarized by Apple — it opens without warnings.

-
-
- InputPilot does not appear under Input Monitoring. -

Some Macs do not populate that list automatically. Add it by hand: System Settings → Privacy & Security → Input Monitoring → + → pick InputPilot in Applications, then switch it on.

-
-
- I granted permission but nothing happens. -

macOS applies Input Monitoring on the next launch. Quit InputPilot and open it again.

-
-
- Does it work with Bluetooth keyboards? -

Yes. Keyboards are identified by vendor, product, transport and name, so a Bluetooth keyboard is recognised again after reconnecting.

-
-
- What does it cost? -

Nothing. It is free and open source under the Apache 2.0 licence.

-
-
+
+

Stop fixing your layout by hand

+

macOS 13 or newer. About a minute to set up.

+ +
-
-

Stop fixing your layout by hand

-

macOS 13 or newer. Takes about a minute to set up.

- -
-
-
+ + + From cf780f0eb3be3a6f09b4d20980051807646c1177 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Tue, 18 Aug 2026 22:24:36 +0200 Subject: [PATCH 3/6] feat: enlarge and animate the ASCII keyboard The keyboard now fills 96% of the terminal (25px, 1129px wide) instead of half of it, and it moves: keys light up as if typed, then the layout flips between German and U.S. - Z and Y swap places, the umlauts become brackets and quotes, and the window title changes to the other keyboard. That is the whole product demonstrated in the hero without a screenshot or a word. Every legend is a span emitted by generate-keyboard-art.py, so the animation addresses individual keys and swaps their labels while the box drawing stays aligned; each row is still exactly 75 characters. Verified in a browser: keypress highlights and layout swaps both observed over a 9-second sample, no page overflow, and the art fits inside its box on a 375px viewport. Animation stops for prefers-reduced-motion and pauses when the tab is hidden. --- docs/generate-keyboard-art.py | 57 +++++++++++----------- docs/index.html | 91 ++++++++++++++++++++++++++++++----- 2 files changed, 110 insertions(+), 38 deletions(-) diff --git a/docs/generate-keyboard-art.py b/docs/generate-keyboard-art.py index dca92f0..2516d2b 100644 --- a/docs/generate-keyboard-art.py +++ b/docs/generate-keyboard-art.py @@ -1,38 +1,41 @@ -# Build a 60% keyboard from box-drawing keycaps. Each unit is 5 chars wide; -# wide keys span multiples of that. Keys touch, so junctions read as a board. +# 60% keyboard in box-drawing keycaps. Every label is wrapped in a span so the +# page can light individual keys and swap legends when the layout changes. U = 5 -def key(label, units=1): - w = U * units - inner = w - 2 - top = "╭" + "─" * inner + "╮" - mid = "│" + label.center(inner) + "│" - bot = "╰" + "─" * inner + "╯" - return top, mid, bot, label +# German legend -> US legend for the keys that differ between the two layouts +SWAP = {"ß":"-", "´":"=", "Z":"Y", "Ü":"[", "+":"]", "#":"\\", + "Ö":";", "Ä":"'", "Y":"Z", "-":"/"} ROWS = [ [("`",1),("1",1),("2",1),("3",1),("4",1),("5",1),("6",1),("7",1),("8",1),("9",1),("0",1),("ß",1),("´",1),("⌫",2)], [("⇥",2),("Q",1),("W",1),("E",1),("R",1),("T",1),("Z",1),("U",1),("I",1),("O",1),("P",1),("Ü",1),("+",1),("#",1)], [("⇪",2),("A",1),("S",1),("D",1),("F",1),("G",1),("H",1),("J",1),("K",1),("L",1),("Ö",1),("Ä",1),("⏎",2)], [("⇧",2),("Y",1),("X",1),("C",1),("V",1),("B",1),("N",1),("M",1),(",",1),(".",1),("-",1),("⇧",3)], - [("⌃",2),("⌥",2),("⌘",2),("",5),("⌘",2),("⌥",1),("⌃",1)], + [("⌃",2),("⌥",2),("⌘",2),(" ",5),("⌘",2),("⌥",1),("⌃",1)], ] -# keys whose position or legend differs between the German and US layouts -DIFF = {"Z","Y","Ü","Ö","Ä","+","ß","´","#","-"} +def esc(c): + return c.replace("&","&").replace("<","<").replace(">",">") -lines = [] -for row in ROWS: - tops, mids, bots = "", "", "" - for label, units in row: - t, m, b, lab = key(label, units) - tops += t; bots += b - if lab in DIFF: - m = m.replace(lab, f"\x00{lab}\x01", 1) - mids += m - lines += [tops, mids, bots] +tops, mids, bots, plain = [], [], [], [] +for ri, row in enumerate(ROWS): + t = m = b = "" + pm = "" + for ci, (label, units) in enumerate(row): + w, inner = U*units, U*units - 2 + t += "╭" + "─"*inner + "╮" + b += "╰" + "─"*inner + "╯" + pad = inner - len(label) + left, right = pad//2, pad - pad//2 + attrs = f' id="k{ri}-{ci}"' + if label in SWAP: + attrs += f' data-us="{esc(SWAP[label])}" class="d"' + cell = f'{esc(label)}' if label.strip() else esc(label) + m += "│" + " "*left + cell + " "*right + "│" + pm += "│" + " "*left + label + " "*right + "│" + tops.append(t); mids.append(m); bots.append(b); plain.append(pm) -widths = {len(l.replace('\x00','').replace('\x01','')) for l in lines} -print("row widths:", widths) -art = "\n".join(lines) -open("kbd.txt","w").write(art) -print(art.replace('\x00','').replace('\x01','')) +html = "\n".join(f"{tops[i]}\n{mids[i]}\n{bots[i]}" for i in range(len(ROWS))) +widths = {len(p) for p in plain} | {len(t) for t in tops} +print("row widths (must be one value):", widths) +print("addressable keys:", html.count("
- inputpilot — active keyboard + inputpilot — Magic Keyboard
-
╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭────────╮
-│ ` ││ 1 ││ 2 ││ 3 ││ 4 ││ 5 ││ 6 ││ 7 ││ 8 ││ 9 ││ 0 ││ ß ││ ´ ││   ⌫    │
+
╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭────────╮
+│ ` ││ 1 ││ 2 ││ 3 ││ 4 ││ 5 ││ 6 ││ 7 ││ 8 ││ 9 ││ 0 ││ ß ││ ´ ││       │
 ╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰────────╯
 ╭────────╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮
-│   ⇥    ││ Q ││ W ││ E ││ R ││ T ││ Z ││ U ││ I ││ O ││ P ││ Ü ││ + ││ # │
+│       ││ Q ││ W ││ E ││ R ││ T ││ Z ││ U ││ I ││ O ││ P ││ Ü ││ + ││ # │
 ╰────────╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯
 ╭────────╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭────────╮
-│   ⇪    ││ A ││ S ││ D ││ F ││ G ││ H ││ J ││ K ││ L ││ Ö ││ Ä ││   ⏎    │
+│       ││ A ││ S ││ D ││ F ││ G ││ H ││ J ││ K ││ L ││ Ö ││ Ä ││       │
 ╰────────╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰────────╯
 ╭────────╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭─────────────╮
-│   ⇧    ││ Y ││ X ││ C ││ V ││ B ││ N ││ M ││ , ││ . ││ - ││      ⇧      │
+│       ││ Y ││ X ││ C ││ V ││ B ││ N ││ M ││ , ││ . ││ - ││            │
 ╰────────╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰─────────────╯
 ╭────────╮╭────────╮╭────────╮╭───────────────────────╮╭────────╮╭───╮╭───╮
-│   ⌃    ││   ⌥    ││   ⌘    ││                       ││   ⌘    ││ ⌥ ││ ⌃ │
+│       ││       ││       ││                       ││       ││  ││  │
 ╰────────╯╰────────╯╰────────╯╰───────────────────────╯╰────────╯╰───╯╰───╯
keys that move between German and U.S. layouts - de ⇄ us + deus
@@ -330,5 +338,66 @@

Stop fixing your layout by hand

.then(d => { if (d && d.tag_name) document.getElementById("ver").textContent = d.tag_name; }) .catch(() => {}); + + From fead46f67cc34381ddd64c43af6001a47b6d2f13 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Tue, 18 Aug 2026 22:34:16 +0200 Subject: [PATCH 4/6] feat: render the hero keyboard as dense ASCII art Replaces the clean box-drawing board with character-density shading: the keyboard is drawn as a bitmap with bright keycaps and legends punched out, then sampled per character cell onto a ' .:-=+*#%@' ramp, so caps read as solid blocks with the letters showing through as dark shapes. 168 columns wide, which is the resolution needed for the legends to survive sampling; a bold face and a gamma curve on the cell means keep partial coverage from washing out to mid-grey. The animation survives the change: each cap's characters are wrapped in a span carrying its US counterpart, so a keypress lights the whole keycap and a layout switch rewrites the legends in place. Every row is still exactly 168 characters after any swap. generate-keyboard-art.py renders the grids, generate-keyboard-html.py emits the markup, so the art can be regenerated rather than hand-edited. Verified: 94% of the terminal width on desktop, fits a 375px viewport with no page overflow, keypresses and layout swaps both observed over a 10s sample. --- docs/generate-keyboard-art.py | 150 ++++++++++++++++++++++++--------- docs/generate-keyboard-html.py | 44 ++++++++++ docs/index.html | 109 +++++++++++++----------- 3 files changed, 215 insertions(+), 88 deletions(-) create mode 100644 docs/generate-keyboard-html.py diff --git a/docs/generate-keyboard-art.py b/docs/generate-keyboard-art.py index 2516d2b..5e9a273 100644 --- a/docs/generate-keyboard-art.py +++ b/docs/generate-keyboard-art.py @@ -1,41 +1,109 @@ -# 60% keyboard in box-drawing keycaps. Every label is wrapped in a span so the -# page can light individual keys and swap legends when the layout changes. -U = 5 -# German legend -> US legend for the keys that differ between the two layouts -SWAP = {"ß":"-", "´":"=", "Z":"Y", "Ü":"[", "+":"]", "#":"\\", - "Ö":";", "Ä":"'", "Y":"Z", "-":"/"} - -ROWS = [ - [("`",1),("1",1),("2",1),("3",1),("4",1),("5",1),("6",1),("7",1),("8",1),("9",1),("0",1),("ß",1),("´",1),("⌫",2)], - [("⇥",2),("Q",1),("W",1),("E",1),("R",1),("T",1),("Z",1),("U",1),("I",1),("O",1),("P",1),("Ü",1),("+",1),("#",1)], - [("⇪",2),("A",1),("S",1),("D",1),("F",1),("G",1),("H",1),("J",1),("K",1),("L",1),("Ö",1),("Ä",1),("⏎",2)], - [("⇧",2),("Y",1),("X",1),("C",1),("V",1),("B",1),("N",1),("M",1),(",",1),(".",1),("-",1),("⇧",3)], - [("⌃",2),("⌥",2),("⌘",2),(" ",5),("⌘",2),("⌥",1),("⌃",1)], -] - -def esc(c): - return c.replace("&","&").replace("<","<").replace(">",">") - -tops, mids, bots, plain = [], [], [], [] -for ri, row in enumerate(ROWS): - t = m = b = "" - pm = "" - for ci, (label, units) in enumerate(row): - w, inner = U*units, U*units - 2 - t += "╭" + "─"*inner + "╮" - b += "╰" + "─"*inner + "╯" - pad = inner - len(label) - left, right = pad//2, pad - pad//2 - attrs = f' id="k{ri}-{ci}"' - if label in SWAP: - attrs += f' data-us="{esc(SWAP[label])}" class="d"' - cell = f'{esc(label)}
' if label.strip() else esc(label) - m += "│" + " "*left + cell + " "*right + "│" - pm += "│" + " "*left + label + " "*right + "│" - tops.append(t); mids.append(m); bots.append(b); plain.append(pm) - -html = "\n".join(f"{tops[i]}\n{mids[i]}\n{bots[i]}" for i in range(len(ROWS))) -widths = {len(p) for p in plain} | {len(t) for t in tops} -print("row widths (must be one value):", widths) -print("addressable keys:", html.count("key map, so the page can light +individual caps and swap legends without the art shifting. +""" +from PIL import Image, ImageDraw, ImageFont +import json + +COLS, CELL_W, CELL_H = 168, 6, 13 # 120 chars wide, cells 8x16 px +UNITS = 15 # keyboard is 15 units wide +W = COLS * CELL_W # 960 px +UNIT = W // UNITS # 64 px per unit +ROWS_KEYS = 5 +H = int(UNIT * ROWS_KEYS * 1.02) # a touch of breathing room +ROWS = H // CELL_H # 20 character rows +RAMP = " .:-=+*#%@" # dark -> bright + +FONT = "/System/Library/Fonts/Supplemental/Arial Bold.ttf" + +LAYOUTS = { + "de": [ + [("`",1),("1",1),("2",1),("3",1),("4",1),("5",1),("6",1),("7",1),("8",1),("9",1),("0",1),("ß",1),("´",1),("<-",2)], + [("->",2),("Q",1),("W",1),("E",1),("R",1),("T",1),("Z",1),("U",1),("I",1),("O",1),("P",1),("Ü",1),("+",1),("#",1)], + [("CAP",2),("A",1),("S",1),("D",1),("F",1),("G",1),("H",1),("J",1),("K",1),("L",1),("Ö",1),("Ä",1),("RET",2)], + [("SHF",2),("Y",1),("X",1),("C",1),("V",1),("B",1),("N",1),("M",1),(",",1),(".",1),("-",1),("SHIFT",3)], + [("CTL",2),("ALT",2),("CMD",2),("",5),("CMD",2),("ALT",1),("CTL",1)], + ], + "us": [ + [("`",1),("1",1),("2",1),("3",1),("4",1),("5",1),("6",1),("7",1),("8",1),("9",1),("0",1),("-",1),("=",1),("<-",2)], + [("->",2),("Q",1),("W",1),("E",1),("R",1),("T",1),("Y",1),("U",1),("I",1),("O",1),("P",1),("[",1),("]",1),("\\",1)], + [("CAP",2),("A",1),("S",1),("D",1),("F",1),("G",1),("H",1),("J",1),("K",1),("L",1),(";",1),("'",1),("RET",2)], + [("SHF",2),("Z",1),("X",1),("C",1),("V",1),("B",1),("N",1),("M",1),(",",1),(".",1),("/",1),("SHIFT",3)], + [("CTL",2),("ALT",2),("CMD",2),("",5),("CMD",2),("ALT",1),("CTL",1)], + ], +} + +def key_boxes(rows): + """(key_id, label, x0,y0,x1,y1) for every cap, in pixels.""" + out = [] + for ri, row in enumerate(rows): + x = 0 + for ci, (label, units) in enumerate(row): + w = units * UNIT + out.append((f"{ri}-{ci}", label, x, ri*UNIT, x+w, ri*UNIT+UNIT)) + x += w + return out + +def render(rows): + img = Image.new("L", (W, H), 0) + d = ImageDraw.Draw(img) + for kid, label, x0, y0, x1, y1 in key_boxes(rows): + pad, r = 4, 8 + d.rounded_rectangle([x0+pad, y0+pad, x1-pad, y1-pad], radius=r, fill=255) + if not label.strip(): + continue + size = 46 if len(label) == 1 else (22 if len(label) <= 3 else 18) + try: + font = ImageFont.truetype(FONT, size) + except OSError: + font = ImageFont.load_default() + bb = d.textbbox((0, 0), label, font=font) + tx = (x0 + x1)/2 - (bb[2]-bb[0])/2 - bb[0] + ty = (y0 + y1)/2 - (bb[3]-bb[1])/2 - bb[1] + d.text((tx, ty), label, font=font, fill=0) # legend punched out + return img + +def to_grid(img): + px = img.load() + grid = [] + for r in range(ROWS): + line = [] + for c in range(COLS): + tot = 0 + for y in range(r*CELL_H, (r+1)*CELL_H): + for x in range(c*CELL_W, (c+1)*CELL_W): + tot += px[x, y] + mean = tot / (CELL_W*CELL_H) / 255.0 + v = mean ** 1.9 # push partial coverage toward the dark end + line.append(RAMP[min(len(RAMP)-1, int(v * len(RAMP)))]) + grid.append("".join(line)) + return grid + +def cell_owner(rows): + """key id owning each character cell, or None for the gaps between caps.""" + boxes = key_boxes(rows) + owner = [] + for r in range(ROWS): + line = [] + cy = r*CELL_H + CELL_H/2 + for c in range(COLS): + cx = c*CELL_W + CELL_W/2 + hit = None + for kid, label, x0, y0, x1, y1 in boxes: + if x0+3 <= cx <= x1-3 and y0+3 <= cy <= y1-3: + hit = kid; break + line.append(hit) + owner.append(line) + return owner + +grids = {name: to_grid(render(rows)) for name, rows in LAYOUTS.items()} +owner = cell_owner(LAYOUTS["de"]) +json.dump({"grids": grids, "owner": owner, "cols": COLS, "rows": ROWS}, + open("ascii_grids.json", "w")) + +print(f"grid {COLS}x{ROWS}") +for line in grids["de"][:8]: + print(line) diff --git a/docs/generate-keyboard-html.py b/docs/generate-keyboard-html.py new file mode 100644 index 0000000..50ab1ad --- /dev/null +++ b/docs/generate-keyboard-html.py @@ -0,0 +1,44 @@ +import json +d = json.load(open("ascii_grids.json")) +de, us, owner = d["grids"]["de"], d["grids"]["us"], d["owner"] +ROWS, COLS = d["rows"], d["cols"] + +def esc(t): + return t.replace("&","&").replace("<","<").replace(">",">") + +out = [] +for r in range(ROWS): + line, i = [], 0 + while i < COLS: + k = owner[r][i] + j = i + while j < COLS and owner[r][j] == k: + j += 1 + seg_de, seg_us = de[r][i:j], us[r][i:j] + if k is None: + line.append(esc(seg_de)) + else: + attr = f' data-key="{k}"' + if seg_us != seg_de: + attr += f' data-us="{esc(seg_us)}"' + line.append(f"{esc(seg_de)}") + i = j + out.append("".join(line)) +art = "\n".join(out) + +# legend -> key id, per layout, for the typing animation +from asciikbd import LAYOUTS +maps = {} +for name, rows in LAYOUTS.items(): + m = {} + for ri, row in enumerate(rows): + for ci, (label, _) in enumerate(row): + if len(label) == 1 and label.strip(): + m[label.upper()] = f"{ri}-{ci}" + maps[name] = m + +open("art.html","w").write(art) +open("keymap.json","w").write(json.dumps(maps)) +print("art:", len(art), "bytes |", art.count("inputpilot — Magic Keyboard
-
╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭────────╮
-│ ` ││ 1 ││ 2 ││ 3 ││ 4 ││ 5 ││ 6 ││ 7 ││ 8 ││ 9 ││ 0 ││ ß ││ ´ ││       │
-╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰────────╯
-╭────────╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮
-│       ││ Q ││ W ││ E ││ R ││ T ││ Z ││ U ││ I ││ O ││ P ││ Ü ││ + ││ # │
-╰────────╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯
-╭────────╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭────────╮
-│       ││ A ││ S ││ D ││ F ││ G ││ H ││ J ││ K ││ L ││ Ö ││ Ä ││       │
-╰────────╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰────────╯
-╭────────╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭───╮╭─────────────╮
-│       ││ Y ││ X ││ C ││ V ││ B ││ N ││ M ││ , ││ . ││ - ││            │
-╰────────╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰───╯╰─────────────╯
-╭────────╮╭────────╮╭────────╮╭───────────────────────╮╭────────╮╭───╮╭───╮
-│       ││       ││       ││                       ││       ││  ││  │
-╰────────╯╰────────╯╰────────╯╰───────────────────────╯╰────────╯╰───╯╰───╯
+
+ -========  -========. :========: .========-  ========-  =========  -========  -========. :========: .========-  ========-  =========  -========  -===================: 
+.@@@%-@@@@= @@@#-:@@@# @@@=-:+@@@ #@@*::+@@@ =@@@@:-@@@.:@@@::-%@@:.@@@--:%@@= @@@--.=@@# @@@=::*@@@ #@@+::+@@@ =@@%:--@@@.:@@%::-@@@:.@@@@=#@@@= @@@@@@@@@@@@@@@@@@@@@ 
+.@@@@@@@@@= @@@*-.@@@# @@@@+.=@@@ #@@@#.:@@@ =@@+ ..@@@.:@@@.-.#@@:.@@# -.*@@= @@@@::@@@# @@@:..-@@@ #@@-:: @@@ =@@:.% @@@.:@@= :.@@@:.@@@@@@@@@= @@@@@@@@@-+*@@@@@@@@@ 
+.@@@@@@@@@= @@@@=.@@@# @@@: :=@@@ #@@+:::@@@ =@@*=:.%@@.:@@%.-.#@@:.@@@.-.*@@= @@@# @@@@# @@@:::-@@@ #@@+::-@@@ =@@#.::@@@.:@@+.=.=@@:.@@@@@@@@@= @@@@@@@@@@@@@@@@@@@@@ 
+ #%%%%%%%%. *%%%%%%%%- +%%%%%%%%+ -%%%%%%%%* .%%%%%%%%#  #%%%%%%%#  #%%%%%%%%. *%%%%%%%%- +%%%%%%%%+ -%%%%%%%%* .%%%%%%%%#  #%%%%%%%#  #%%%%%%%%. *%%%%%%%%%%%%%%%%%%%+ 
+ :------------------:  .--------.  :-------.  :-------:  :-------:  :-------:  .-------:  .--------.  :-------.  :-------:  :-------:  :-------:  .-------:  .--------. 
+.@@@@@@@@@@@@@@@@@@@@# @@%:--:%@@ #*-@=:@+=@ =@@----#@@.:@@----*@@:.@@*-:-=@@= @@@--::@@# @@*:@@.%@@ #@@@+=@@@@ =@@+---#@@.:@@=---#@@:.@@*-#-*@@= @@@@+%@@@# @@@@-#=@@@ 
+.@@@@@@@@%*-*@@@@@@@@# @@.:@@:.@@ #@ + .- *@ =@@ .--@@@.:@@ .-.-@@:.@@@@ *@@@= @@@%..#@@# @@+ @@ %@@ #@@@:.@@@@ =@+ @@+ @@.:@@..= -@@:.@@.:@= @@= @@%- :+@@# @@@:  :@@@ 
+.@@@@@@@@@@@@@@@@@@@@# @@*.-  -@@ #@- =% .@@ =@@ .--#@@.:@@ =# :@@:.@@@@ *@@@= @@+ .--@@# @@# -- @@@ #@@@:.@@@@ =@% -=.:@@.:@@.:%@@@@:.@@:.%:.@@= @@@@+%@@@# @@@ : -@@@ 
+ @@@@@@@@@@@@@@@@@@@@= #@@@@@@%@# =@@@@@@@@% :@@@@@@@@@ .@@@@@@@@@. @@@@@@@@@: %@@@@@@@@= #@@@@@@@@# =@@@@@@@@% :@@@@%@@@@ .@@@@@@@@@. @@@*++@@@: %@@@@@@@@= #@@@@@@@@# 
+ .:::::::::::::::::::   ::::::::   ::::::::   .:::::::.  .:::::::.  .:::::::.   ::::::::   ::::::::   ::::::::   .:::::::.  .:::::::.  .:::::::.   ::::::::...::::::::  
+.@@@@@@@@@@@@@@@@@@@@* @@@@=+@@@@ *@@*--*@@@ =@@=-=+@@@.:@@%---*@@:.@@@=--*@@= @@#+@%=@@* @@@@@+*@@@ *@@-@%=#@@ =@@#+@@@@@.:@@@:=-@@@:.@@@+*=@@@= @@@@@@@@@@@@@@@@@@@@@ 
+.@@@@@@++**:%.:@@@@@@# @@@-: +@@@ #@@ :--@@@ =@@ #@.:@@.:@@+ ==@@@:.@@ =@=:@@= @@- -: @@# @@@@@.:@@@ #@% : *@@@ =@@-.@@@@@.:@@ -#- @@:.@@@:..@@@= @@@@@@@.:=:+-=@@@@@@@ 
+.@@@@@@%+*+***@@@@@@@# @@+ -- #@@ #@@:+- #@@ =@@ =+ =@@.:@@+ %%@@@:.@@.:*- #@= @@-.@# @@# @@@.+ =@@@ #@% #- #@@ =@@- **%@@.:@% *@* %@:.@@= - :@@= @@@@@@@*+*+##%@@@@@@@ 
+ @@@@@@@@@@@@@@@@@@@@* @@#%@@%%@@ *@@%**%@@@ -@@###@@@@ :@@@#@@@@@: @@@%**%@@- @@%%@@#@@* @@@@#%@@@@ *@@#@@%#@@ -@@%###%@@ :@@%=-=%@@: @@-%@@-%@- @@@@@@@@@@@@@@@@@@@@@ 
+  :::::::::::::::::::   ::::::::   ::::::::   .:::::::   .:::::::.   :::::::.   ::::::::   ::::::::   ::::::::   .:::::::   .:::::::.   ::::::::  .:::::::::::::::::::  
+ @@@@@@@@@@@@@@@@@@@@* @@%*@@*%@@ *@@*@@*%@@ -@@@*+*@@@ :@@*%@@*@@: @@#***@@@- @@%*@@*@@* @@#*@@*#@@ *@@@@@@@@@ -@@@@@@@@@ :@@@@@@@@@: @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 
+.@@@@@@%-*=*+:+@@@@@@# @@@:.:.@@@ #@@=  -@@@ =@@ +%-*@@.:@@- @.:@@:.@@..-.:@@= @@- :# @@# @@. -- .@@ #@@@*=@@@@ =@@@%*@@@@.:@@@#-+@@@:.@@@@@@@@@@@%=*#++=*=@@@@@@@@@@@@ 
+.@@@@@@%===#+=@@@@@@@# @@@@:.@@@@ #@@-  -@@@ =@@ +%-*@@.:@@@.  @@@:.@@..*: @@= @@-.*. @@# @@.-  -.@@ #@@@#*@@@@ =@@@%*@@@@.:@@@@@@@@@:.@@@@@@@@@@@%-+*+=*@*@@@@@@@@@@@@ 
+.@@@@@@@@@@@@@@@@@@@@* @@@@#*@@@@ *@%+@@+#@@ =@@@*+*@@@.:@@@@=%@@@:.@@*==+%@@= @@#*@#+@@* @@*#**%*@@ *@@@@@@@@@ =@@@@@@@@@.:@@@@@@@@@:.@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 
+ .:::::::::::::::::::   ::::::::   ::::::::   .:::::::.  .:::::::.  .::::::::  .::::::::   ::::::::   ::::::::.  ::::::::.  .:::::::.  .::::::::...::::::::...::::::::  
+ @@@@@@@@@@@@@@@@@@@@= #@@@@@@@@@@@@@@@@@@@% :@@@@@@@@@@@@@@@@@@@@. @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ .@@@@@@@@@@@@@@@@@@@@: %@@@@@@@@= #@@@@@@@@# 
+.@@@@@@@++#=*#@@@@@@@# @@@@@@@%=@*@++@@@@@@@ =@@@@@@++**=++*@@@@@@:.@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.:@@@@@@++#+++++@@@@@@= @@=%+@+=@# @@+**=+%@@ 
+.@@@@@@%-+@-#-#@@@@@@# @@@@@@@-=+:*+#@@@@@@@ =@@@@@%-+=-:-+-@@@@@@:.@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.:@@@@@@-+*::---@@@@@@= @+=--*#+@# @#=*@-+=%@ 
+.@@@@@@@@@@@@@@@@@@@@# @@@@@@@@@@@@@@@@@@@@@ =@@@@@@@@@@@@@@@@@@@@:.@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.:@@@@@@@@@@@@@@@@@@@@= @@@@@@@@@# @@@@@@@@@@ 
+ :------------------:  .-------------------.  :------------------:  :----------------------------------------------------:  :------------------:  .-------:  .--------. 
+
keys that move between German and U.S. layouts @@ -343,56 +356,58 @@

Stop fixing your layout by hand

(() => { const art = document.getElementById("kbd"); if (!art) return; - const keys = [...art.querySelectorAll("i")]; - const variants = keys.filter(k => k.dataset.us); - variants.forEach(k => k.dataset.de = k.textContent); - - let index = new Map(); - const reindex = () => { - index = new Map(); - keys.forEach(k => index.set(k.textContent.trim().toUpperCase(), k)); - }; - reindex(); - + const KEYMAP = {"de": {"`": "0-0", "1": "0-1", "2": "0-2", "3": "0-3", "4": "0-4", "5": "0-5", "6": "0-6", "7": "0-7", "8": "0-8", "9": "0-9", "0": "0-10", "SS": "0-11", "\u00b4": "0-12", "Q": "1-1", "W": "1-2", "E": "1-3", "R": "1-4", "T": "1-5", "Z": "1-6", "U": "1-7", "I": "1-8", "O": "1-9", "P": "1-10", "\u00dc": "1-11", "+": "1-12", "#": "1-13", "A": "2-1", "S": "2-2", "D": "2-3", "F": "2-4", "G": "2-5", "H": "2-6", "J": "2-7", "K": "2-8", "L": "2-9", "\u00d6": "2-10", "\u00c4": "2-11", "Y": "3-1", "X": "3-2", "C": "3-3", "V": "3-4", "B": "3-5", "N": "3-6", "M": "3-7", ",": "3-8", ".": "3-9", "-": "3-10"}, "us": {"`": "0-0", "1": "0-1", "2": "0-2", "3": "0-3", "4": "0-4", "5": "0-5", "6": "0-6", "7": "0-7", "8": "0-8", "9": "0-9", "0": "0-10", "-": "0-11", "=": "0-12", "Q": "1-1", "W": "1-2", "E": "1-3", "R": "1-4", "T": "1-5", "Y": "1-6", "U": "1-7", "I": "1-8", "O": "1-9", "P": "1-10", "[": "1-11", "]": "1-12", "\\": "1-13", "A": "2-1", "S": "2-2", "D": "2-3", "F": "2-4", "G": "2-5", "H": "2-6", "J": "2-7", "K": "2-8", "L": "2-9", ";": "2-10", "'": "2-11", "Z": "3-1", "X": "3-2", "C": "3-3", "V": "3-4", "B": "3-5", "N": "3-6", "M": "3-7", ",": "3-8", ".": "3-9", "/": "3-10"}}; + const segs = [...art.querySelectorAll("i")]; + const swappable = segs.filter(s => s.dataset.us); + swappable.forEach(s => { s.dataset.de = s.textContent; s.classList.add("sw"); }); + const byKey = new Map(); + segs.forEach(s => { + const k = s.dataset.key; + if (!byKey.has(k)) byKey.set(k, []); + byKey.get(k).push(s); + }); + + let layout = "de"; const sleep = ms => new Promise(r => setTimeout(r, ms)); const idle = async () => { while (document.hidden) await sleep(400); }; async function press(ch) { - const el = index.get(ch.toUpperCase()); - if (!el) return; - el.classList.add("lit"); - await sleep(120); - el.classList.remove("lit"); + const id = KEYMAP[layout][ch.toUpperCase()]; + const parts = id && byKey.get(id); + if (!parts) return; + parts.forEach(p => p.classList.add("lit")); + await sleep(135); + parts.forEach(p => p.classList.remove("lit")); } - async function type(word, gap = 130) { + async function type(word, gap = 120) { for (const ch of word) { await press(ch); await sleep(gap); } } async function setLayout(next) { - variants.forEach(k => { - k.classList.add("flip"); - k.textContent = next === "us" ? k.dataset.us : k.dataset.de; + layout = next; + swappable.forEach(s => { + s.classList.add("flip"); + s.textContent = next === "us" ? s.dataset.us : s.dataset.de; }); - reindex(); document.getElementById("devname").textContent = next === "us" ? "Keychron K2" : "Magic Keyboard"; document.getElementById("lay-de").classList.toggle("on", next === "de"); document.getElementById("lay-us").classList.toggle("on", next === "us"); - await sleep(560); - variants.forEach(k => k.classList.remove("flip")); + await sleep(620); + swappable.forEach(s => s.classList.remove("flip")); } async function run() { await sleep(900); for (;;) { await idle(); await type("GRÜSSE"); - await sleep(950); + await sleep(1000); await idle(); await setLayout("us"); - await sleep(420); + await sleep(450); await idle(); await type("HELLO"); - await sleep(950); + await sleep(1000); await idle(); await setLayout("de"); - await sleep(420); + await sleep(450); } } From b49d1a9ffd1ccc7ccc77eb4dcb2e08ae23f5b228 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Tue, 18 Aug 2026 22:40:32 +0200 Subject: [PATCH 5/6] feat: readable legends and an ANSI/ISO layout animation Readability came from fixing the sampling aspect: cells were 6x13px while a browser renders monospace at roughly 6x10, so the board was drawn squashed and wasted vertical resolution on the legends. Sampling at the aspect the page actually renders gives 34 rows instead of 26, and the letters survive. Supersampling 3x before downsampling cleans up the keycap edges. The hero now animates between the two physical boards rather than swapping legends: ISO with its L-shaped Enter, extra key beside left Shift and 13-key home row, against ANSI with a wide flat Enter and long left Shift. Both are built from real 60% key units and every row totals exactly 15u, which is asserted before rendering; a units error in the bottom row (14u) surfaced that way. Both boards render at identical size so the cross-fade cannot shift the layout. Verified in a browser: aspect 2.97 against a real keyboard's 3.00, 94% of the terminal width, 34x168 characters on both boards, keypresses and board swaps observed across a full 10s cycle, no page overflow. --- docs/generate-keyboard-art.py | 162 ++++++++++++++-------------- docs/generate-keyboard-html.py | 60 ++++------- docs/index.html | 190 +++++++++++++++++++++------------ 3 files changed, 228 insertions(+), 184 deletions(-) diff --git a/docs/generate-keyboard-art.py b/docs/generate-keyboard-art.py index 5e9a273..d2303a2 100644 --- a/docs/generate-keyboard-art.py +++ b/docs/generate-keyboard-art.py @@ -1,109 +1,111 @@ -"""Render a keyboard as dense ASCII art. +"""Render ANSI (US) and ISO (German) keyboards as dense ASCII art. -Draws the board as a bitmap (bright keycaps, legends punched out), then -samples it per character cell and maps brightness onto a density ramp. -Emits one grid per layout plus the cell->key map, so the page can light -individual caps and swap legends without the art shifting. +Cells are sampled at the same 1:1.67 aspect the browser renders monospace at, +so the board keeps its real proportions and legends get enough vertical +resolution to stay readable. """ from PIL import Image, ImageDraw, ImageFont import json -COLS, CELL_W, CELL_H = 168, 6, 13 # 120 chars wide, cells 8x16 px -UNITS = 15 # keyboard is 15 units wide -W = COLS * CELL_W # 960 px -UNIT = W // UNITS # 64 px per unit -ROWS_KEYS = 5 -H = int(UNIT * ROWS_KEYS * 1.02) # a touch of breathing room -ROWS = H // CELL_H # 20 character rows -RAMP = " .:-=+*#%@" # dark -> bright +COLS, CELL_W, CELL_H = 168, 6, 10 +W = COLS * CELL_W # 1008 +UNITS, KEY_ROWS = 15.0, 5 +UNIT = W / UNITS # 67.2 px +H = int(UNIT * KEY_ROWS) # 336 +ROWS = round(H / CELL_H) # 34 +SS = 3 # supersample for clean edges +RAMP = " .:-=+*#%@" +FONT_B = "/System/Library/Fonts/Supplemental/Arial Bold.ttf" -FONT = "/System/Library/Fonts/Supplemental/Arial Bold.ttf" +# (label, units) — ISO gains a key beside left Shift and a tall L-shaped Enter +ANSI = [ + [("`",1),("1",1),("2",1),("3",1),("4",1),("5",1),("6",1),("7",1),("8",1),("9",1),("0",1),("-",1),("=",1),("BACK",2)], + [("TAB",1.5),("Q",1),("W",1),("E",1),("R",1),("T",1),("Y",1),("U",1),("I",1),("O",1),("P",1),("[",1),("]",1),("\\",1.5)], + [("CAPS",1.75),("A",1),("S",1),("D",1),("F",1),("G",1),("H",1),("J",1),("K",1),("L",1),(";",1),("'",1),("ENTER",2.25)], + [("SHIFT",2.25),("Z",1),("X",1),("C",1),("V",1),("B",1),("N",1),("M",1),(",",1),(".",1),("/",1),("SHIFT",2.75)], + [("CTRL",1.25),("OPT",1.25),("CMD",1.25),("",6.25),("CMD",1.25),("OPT",1.25),("CTRL",1.25),("FN",1.25)], +] +ISO = [ + [("^",1),("1",1),("2",1),("3",1),("4",1),("5",1),("6",1),("7",1),("8",1),("9",1),("0",1),("ß",1),("´",1),("BACK",2)], + [("TAB",1.5),("Q",1),("W",1),("E",1),("R",1),("T",1),("Z",1),("U",1),("I",1),("O",1),("P",1),("Ü",1),("+",1),("ENTER",1.5)], + [("CAPS",1.75),("A",1),("S",1),("D",1),("F",1),("G",1),("H",1),("J",1),("K",1),("L",1),("Ö",1),("Ä",1),("#",1),("ENTER",1.25)], + [("SHIFT",1.25),("<",1),("Y",1),("X",1),("C",1),("V",1),("B",1),("N",1),("M",1),(",",1),(".",1),("-",1),("SHIFT",2.75)], + [("CTRL",1.25),("OPT",1.25),("CMD",1.25),("",6.25),("CMD",1.25),("OPT",1.25),("CTRL",1.25),("FN",1.25)], +] -LAYOUTS = { - "de": [ - [("`",1),("1",1),("2",1),("3",1),("4",1),("5",1),("6",1),("7",1),("8",1),("9",1),("0",1),("ß",1),("´",1),("<-",2)], - [("->",2),("Q",1),("W",1),("E",1),("R",1),("T",1),("Z",1),("U",1),("I",1),("O",1),("P",1),("Ü",1),("+",1),("#",1)], - [("CAP",2),("A",1),("S",1),("D",1),("F",1),("G",1),("H",1),("J",1),("K",1),("L",1),("Ö",1),("Ä",1),("RET",2)], - [("SHF",2),("Y",1),("X",1),("C",1),("V",1),("B",1),("N",1),("M",1),(",",1),(".",1),("-",1),("SHIFT",3)], - [("CTL",2),("ALT",2),("CMD",2),("",5),("CMD",2),("ALT",1),("CTL",1)], - ], - "us": [ - [("`",1),("1",1),("2",1),("3",1),("4",1),("5",1),("6",1),("7",1),("8",1),("9",1),("0",1),("-",1),("=",1),("<-",2)], - [("->",2),("Q",1),("W",1),("E",1),("R",1),("T",1),("Y",1),("U",1),("I",1),("O",1),("P",1),("[",1),("]",1),("\\",1)], - [("CAP",2),("A",1),("S",1),("D",1),("F",1),("G",1),("H",1),("J",1),("K",1),("L",1),(";",1),("'",1),("RET",2)], - [("SHF",2),("Z",1),("X",1),("C",1),("V",1),("B",1),("N",1),("M",1),(",",1),(".",1),("/",1),("SHIFT",3)], - [("CTL",2),("ALT",2),("CMD",2),("",5),("CMD",2),("ALT",1),("CTL",1)], - ], -} - -def key_boxes(rows): - """(key_id, label, x0,y0,x1,y1) for every cap, in pixels.""" +def boxes(rows): out = [] for ri, row in enumerate(rows): - x = 0 - for ci, (label, units) in enumerate(row): - w = units * UNIT - out.append((f"{ri}-{ci}", label, x, ri*UNIT, x+w, ri*UNIT+UNIT)) + x = 0.0 + for ci, (label, u) in enumerate(row): + w = u * UNIT + # the two ENTER halves in ISO share one id so they light together + kid = "enter" if label == "ENTER" else f"{ri}-{ci}" + out.append((kid, label, x, ri*UNIT, x+w, ri*UNIT+UNIT)) x += w return out def render(rows): - img = Image.new("L", (W, H), 0) + img = Image.new("L", (W*SS, H*SS), 0) d = ImageDraw.Draw(img) - for kid, label, x0, y0, x1, y1 in key_boxes(rows): - pad, r = 4, 8 - d.rounded_rectangle([x0+pad, y0+pad, x1-pad, y1-pad], radius=r, fill=255) - if not label.strip(): + pad, rad = 3*SS, 7*SS + for kid, label, x0, y0, x1, y1 in boxes(rows): + d.rounded_rectangle([x0*SS+pad, y0*SS+pad, x1*SS-pad, y1*SS-pad], radius=rad, fill=255) + # bridge the ISO enter halves so the L reads as one cap + ents = [b for b in boxes(rows) if b[0] == "enter"] + if len(ents) == 2: + (_,_,ax0,ay0,ax1,ay1), (_,_,bx0,by0,bx1,by1) = ents + d.rectangle([max(ax0,bx0)*SS+pad, ay0*SS+pad, min(ax1,bx1)*SS-pad, by1*SS-pad], fill=255) + for kid, label, x0, y0, x1, y1 in boxes(rows): + if not label.strip() or (kid == "enter" and y0 > UNIT*1.5): continue - size = 46 if len(label) == 1 else (22 if len(label) <= 3 else 18) - try: - font = ImageFont.truetype(FONT, size) - except OSError: - font = ImageFont.load_default() - bb = d.textbbox((0, 0), label, font=font) - tx = (x0 + x1)/2 - (bb[2]-bb[0])/2 - bb[0] - ty = (y0 + y1)/2 - (bb[3]-bb[1])/2 - bb[1] - d.text((tx, ty), label, font=font, fill=0) # legend punched out - return img + size = int((46 if len(label) == 1 else 20 if len(label) <= 5 else 17) * SS) + f = ImageFont.truetype(FONT_B, size) + bb = d.textbbox((0,0), label, font=f) + cx, cy = (x0+x1)/2*SS, (y0+y1)/2*SS + d.text((cx-(bb[2]-bb[0])/2-bb[0], cy-(bb[3]-bb[1])/2-bb[1]), label, font=f, fill=0) + return img.resize((W, H), Image.LANCZOS) -def to_grid(img): +def grid(img): px = img.load() - grid = [] + g = [] for r in range(ROWS): line = [] for c in range(COLS): - tot = 0 - for y in range(r*CELL_H, (r+1)*CELL_H): + tot = n = 0 + for y in range(r*CELL_H, min((r+1)*CELL_H, H)): for x in range(c*CELL_W, (c+1)*CELL_W): - tot += px[x, y] - mean = tot / (CELL_W*CELL_H) / 255.0 - v = mean ** 1.9 # push partial coverage toward the dark end - line.append(RAMP[min(len(RAMP)-1, int(v * len(RAMP)))]) - grid.append("".join(line)) - return grid + tot += px[x, y]; n += 1 + v = (tot/n/255.0) ** 2.2 if n else 0 + line.append(RAMP[min(len(RAMP)-1, int(v*len(RAMP)))]) + g.append("".join(line)) + return g -def cell_owner(rows): - """key id owning each character cell, or None for the gaps between caps.""" - boxes = key_boxes(rows) - owner = [] +def owners(rows): + bs = boxes(rows) + o = [] for r in range(ROWS): - line = [] cy = r*CELL_H + CELL_H/2 + line = [] for c in range(COLS): cx = c*CELL_W + CELL_W/2 - hit = None - for kid, label, x0, y0, x1, y1 in boxes: - if x0+3 <= cx <= x1-3 and y0+3 <= cy <= y1-3: - hit = kid; break + hit = next((k for k,l,x0,y0,x1,y1 in bs if x0+2 <= cx <= x1-2 and y0+2 <= cy <= y1-2), None) line.append(hit) - owner.append(line) - return owner + o.append(line) + return o -grids = {name: to_grid(render(rows)) for name, rows in LAYOUTS.items()} -owner = cell_owner(LAYOUTS["de"]) -json.dump({"grids": grids, "owner": owner, "cols": COLS, "rows": ROWS}, - open("ascii_grids.json", "w")) +def keymap(rows): + m = {} + for ri, row in enumerate(rows): + x = 0 + for ci, (label, u) in enumerate(row): + if len(label) == 1 and label.strip(): + m[label.upper()] = f"{ri}-{ci}" + return m -print(f"grid {COLS}x{ROWS}") -for line in grids["de"][:8]: - print(line) +data = {"cols": COLS, "rows": ROWS, "layouts": {}} +for name, rows in (("ansi", ANSI), ("iso", ISO)): + data["layouts"][name] = {"grid": grid(render(rows)), "owner": owners(rows), "keys": keymap(rows)} +json.dump(data, open("kbd3.json","w")) +print(f"{COLS}x{ROWS} cells, aspect {COLS*CELL_W/(ROWS*CELL_H):.2f} (keyboard is 3.00)") +for l in data["layouts"]["iso"]["grid"][12:19]: print(l) diff --git a/docs/generate-keyboard-html.py b/docs/generate-keyboard-html.py index 50ab1ad..9917209 100644 --- a/docs/generate-keyboard-html.py +++ b/docs/generate-keyboard-html.py @@ -1,44 +1,28 @@ import json -d = json.load(open("ascii_grids.json")) -de, us, owner = d["grids"]["de"], d["grids"]["us"], d["owner"] -ROWS, COLS = d["rows"], d["cols"] +d = json.load(open("kbd3.json")) +COLS, ROWS = d["cols"], d["rows"] def esc(t): return t.replace("&","&").replace("<","<").replace(">",">") -out = [] -for r in range(ROWS): - line, i = [], 0 - while i < COLS: - k = owner[r][i] - j = i - while j < COLS and owner[r][j] == k: - j += 1 - seg_de, seg_us = de[r][i:j], us[r][i:j] - if k is None: - line.append(esc(seg_de)) - else: - attr = f' data-key="{k}"' - if seg_us != seg_de: - attr += f' data-us="{esc(seg_us)}"' - line.append(f"{esc(seg_de)}
") - i = j - out.append("".join(line)) -art = "\n".join(out) +def markup(layout): + grid, owner = layout["grid"], layout["owner"] + lines = [] + for r in range(ROWS): + out, i = [], 0 + while i < COLS: + k = owner[r][i] + j = i + while j < COLS and owner[r][j] == k: + j += 1 + seg = esc(grid[r][i:j]) + out.append(seg if k is None else f'{seg}') + i = j + lines.append("".join(out)) + return "\n".join(lines) -# legend -> key id, per layout, for the typing animation -from asciikbd import LAYOUTS -maps = {} -for name, rows in LAYOUTS.items(): - m = {} - for ri, row in enumerate(rows): - for ci, (label, _) in enumerate(row): - if len(label) == 1 and label.strip(): - m[label.upper()] = f"{ri}-{ci}" - maps[name] = m - -open("art.html","w").write(art) -open("keymap.json","w").write(json.dumps(maps)) -print("art:", len(art), "bytes |", art.count(" pre.art { grid-area:1/1; opacity:0; transition:opacity .34s ease; pointer-events:none; } + .art-stack > pre.art.on { opacity:1; } pre.art { margin:0; display:inline-block; text-align:left; color:#6C7480; /* 75 chars wide at ~0.6em each, so this fills the terminal without overflowing */ - font-size:clamp(3.1px, 0.86vw, 11.4px); line-height:1.0; letter-spacing:0; + font-size:clamp(3.1px, 0.86vw, 11.4px); line-height:1.0; white-space:pre; letter-spacing:0; } /* every legend is addressable so keys can light up and swap on layout change */ pre.art i { font-style:normal; color:#8E97A5; transition:color .09s ease, text-shadow .09s ease; } - pre.art i.sw { color:#C08A5E; } + pre.art i.lit { color:#FFF; text-shadow:0 0 .6em var(--blue), 0 0 1.4em var(--blue); } pre.art i.flip { color:var(--orange); text-shadow:0 0 .7em var(--orange); } .layout-state { color:var(--faint); } @@ -174,38 +177,84 @@ inputpilot — Magic Keyboard
-
- -========  -========. :========: .========-  ========-  =========  -========  -========. :========: .========-  ========-  =========  -========  -===================: 
-.@@@%-@@@@= @@@#-:@@@# @@@=-:+@@@ #@@*::+@@@ =@@@@:-@@@.:@@@::-%@@:.@@@--:%@@= @@@--.=@@# @@@=::*@@@ #@@+::+@@@ =@@%:--@@@.:@@%::-@@@:.@@@@=#@@@= @@@@@@@@@@@@@@@@@@@@@ 
-.@@@@@@@@@= @@@*-.@@@# @@@@+.=@@@ #@@@#.:@@@ =@@+ ..@@@.:@@@.-.#@@:.@@# -.*@@= @@@@::@@@# @@@:..-@@@ #@@-:: @@@ =@@:.% @@@.:@@= :.@@@:.@@@@@@@@@= @@@@@@@@@-+*@@@@@@@@@ 
-.@@@@@@@@@= @@@@=.@@@# @@@: :=@@@ #@@+:::@@@ =@@*=:.%@@.:@@%.-.#@@:.@@@.-.*@@= @@@# @@@@# @@@:::-@@@ #@@+::-@@@ =@@#.::@@@.:@@+.=.=@@:.@@@@@@@@@= @@@@@@@@@@@@@@@@@@@@@ 
- #%%%%%%%%. *%%%%%%%%- +%%%%%%%%+ -%%%%%%%%* .%%%%%%%%#  #%%%%%%%#  #%%%%%%%%. *%%%%%%%%- +%%%%%%%%+ -%%%%%%%%* .%%%%%%%%#  #%%%%%%%#  #%%%%%%%%. *%%%%%%%%%%%%%%%%%%%+ 
- :------------------:  .--------.  :-------.  :-------:  :-------:  :-------:  .-------:  .--------.  :-------.  :-------:  :-------:  :-------:  .-------:  .--------. 
-.@@@@@@@@@@@@@@@@@@@@# @@%:--:%@@ #*-@=:@+=@ =@@----#@@.:@@----*@@:.@@*-:-=@@= @@@--::@@# @@*:@@.%@@ #@@@+=@@@@ =@@+---#@@.:@@=---#@@:.@@*-#-*@@= @@@@+%@@@# @@@@-#=@@@ 
-.@@@@@@@@%*-*@@@@@@@@# @@.:@@:.@@ #@ + .- *@ =@@ .--@@@.:@@ .-.-@@:.@@@@ *@@@= @@@%..#@@# @@+ @@ %@@ #@@@:.@@@@ =@+ @@+ @@.:@@..= -@@:.@@.:@= @@= @@%- :+@@# @@@:  :@@@ 
-.@@@@@@@@@@@@@@@@@@@@# @@*.-  -@@ #@- =% .@@ =@@ .--#@@.:@@ =# :@@:.@@@@ *@@@= @@+ .--@@# @@# -- @@@ #@@@:.@@@@ =@% -=.:@@.:@@.:%@@@@:.@@:.%:.@@= @@@@+%@@@# @@@ : -@@@ 
- @@@@@@@@@@@@@@@@@@@@= #@@@@@@%@# =@@@@@@@@% :@@@@@@@@@ .@@@@@@@@@. @@@@@@@@@: %@@@@@@@@= #@@@@@@@@# =@@@@@@@@% :@@@@%@@@@ .@@@@@@@@@. @@@*++@@@: %@@@@@@@@= #@@@@@@@@# 
- .:::::::::::::::::::   ::::::::   ::::::::   .:::::::.  .:::::::.  .:::::::.   ::::::::   ::::::::   ::::::::   .:::::::.  .:::::::.  .:::::::.   ::::::::...::::::::  
-.@@@@@@@@@@@@@@@@@@@@* @@@@=+@@@@ *@@*--*@@@ =@@=-=+@@@.:@@%---*@@:.@@@=--*@@= @@#+@%=@@* @@@@@+*@@@ *@@-@%=#@@ =@@#+@@@@@.:@@@:=-@@@:.@@@+*=@@@= @@@@@@@@@@@@@@@@@@@@@ 
-.@@@@@@++**:%.:@@@@@@# @@@-: +@@@ #@@ :--@@@ =@@ #@.:@@.:@@+ ==@@@:.@@ =@=:@@= @@- -: @@# @@@@@.:@@@ #@% : *@@@ =@@-.@@@@@.:@@ -#- @@:.@@@:..@@@= @@@@@@@.:=:+-=@@@@@@@ 
-.@@@@@@%+*+***@@@@@@@# @@+ -- #@@ #@@:+- #@@ =@@ =+ =@@.:@@+ %%@@@:.@@.:*- #@= @@-.@# @@# @@@.+ =@@@ #@% #- #@@ =@@- **%@@.:@% *@* %@:.@@= - :@@= @@@@@@@*+*+##%@@@@@@@ 
- @@@@@@@@@@@@@@@@@@@@* @@#%@@%%@@ *@@%**%@@@ -@@###@@@@ :@@@#@@@@@: @@@%**%@@- @@%%@@#@@* @@@@#%@@@@ *@@#@@%#@@ -@@%###%@@ :@@%=-=%@@: @@-%@@-%@- @@@@@@@@@@@@@@@@@@@@@ 
-  :::::::::::::::::::   ::::::::   ::::::::   .:::::::   .:::::::.   :::::::.   ::::::::   ::::::::   ::::::::   .:::::::   .:::::::.   ::::::::  .:::::::::::::::::::  
- @@@@@@@@@@@@@@@@@@@@* @@%*@@*%@@ *@@*@@*%@@ -@@@*+*@@@ :@@*%@@*@@: @@#***@@@- @@%*@@*@@* @@#*@@*#@@ *@@@@@@@@@ -@@@@@@@@@ :@@@@@@@@@: @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 
-.@@@@@@%-*=*+:+@@@@@@# @@@:.:.@@@ #@@=  -@@@ =@@ +%-*@@.:@@- @.:@@:.@@..-.:@@= @@- :# @@# @@. -- .@@ #@@@*=@@@@ =@@@%*@@@@.:@@@#-+@@@:.@@@@@@@@@@@%=*#++=*=@@@@@@@@@@@@ 
-.@@@@@@%===#+=@@@@@@@# @@@@:.@@@@ #@@-  -@@@ =@@ +%-*@@.:@@@.  @@@:.@@..*: @@= @@-.*. @@# @@.-  -.@@ #@@@#*@@@@ =@@@%*@@@@.:@@@@@@@@@:.@@@@@@@@@@@%-+*+=*@*@@@@@@@@@@@@ 
-.@@@@@@@@@@@@@@@@@@@@* @@@@#*@@@@ *@%+@@+#@@ =@@@*+*@@@.:@@@@=%@@@:.@@*==+%@@= @@#*@#+@@* @@*#**%*@@ *@@@@@@@@@ =@@@@@@@@@.:@@@@@@@@@:.@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 
- .:::::::::::::::::::   ::::::::   ::::::::   .:::::::.  .:::::::.  .::::::::  .::::::::   ::::::::   ::::::::.  ::::::::.  .:::::::.  .::::::::...::::::::...::::::::  
- @@@@@@@@@@@@@@@@@@@@= #@@@@@@@@@@@@@@@@@@@% :@@@@@@@@@@@@@@@@@@@@. @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ .@@@@@@@@@@@@@@@@@@@@: %@@@@@@@@= #@@@@@@@@# 
-.@@@@@@@++#=*#@@@@@@@# @@@@@@@%=@*@++@@@@@@@ =@@@@@@++**=++*@@@@@@:.@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.:@@@@@@++#+++++@@@@@@= @@=%+@+=@# @@+**=+%@@ 
-.@@@@@@%-+@-#-#@@@@@@# @@@@@@@-=+:*+#@@@@@@@ =@@@@@%-+=-:-+-@@@@@@:.@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.:@@@@@@-+*::---@@@@@@= @+=--*#+@# @#=*@-+=%@ 
-.@@@@@@@@@@@@@@@@@@@@# @@@@@@@@@@@@@@@@@@@@@ =@@@@@@@@@@@@@@@@@@@@:.@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.:@@@@@@@@@@@@@@@@@@@@= @@@@@@@@@# @@@@@@@@@@ 
- :------------------:  .-------------------.  :------------------:  :----------------------------------------------------:  :------------------:  .-------:  .--------. 
+
+
+ =========. -========. :========- .========-  =========  =========. -========. :========- .========-  =========  =========. -========. :========- .==================== 
+:@@@@=@@@@+ @@@@%*@@@% @@@@++@@@@ #@@@+=%@@@ =@@@@%*@@@::@@@*==%@@+ @@@@==@@@% @@@++++@@@ #@@@+=%@@@ =@@@+=%@@@::@@@#=#@@@+ @@@*-+@@@% @@@@%+@@@@ #@@@@@@@@@@@@@@@@@@@@:
+:@@@: .@@@+ @@@-  @@@% @@@::. @@@ #@@+-..@@@ =@@@*  @@@::@@@ .:%@@+ @@@ ::+@@% @@@=- -@@@ #@@- : @@@ =@@- = %@@::@@% + *@@+ @@@ = %@@% @@@@*#@@@@ #@@@@@++@+@++*#%@@@@@:
+:@@@:@-%@@+ @@@%- @@@% @@@@= +@@@ #@@@#..@@@ =@@= . %@@::@@@.- =@@+ @@@ . :@@% @@@@::@@@@ #@@= ..@@@ =@@* . *@@::@@+ @ =@@+ @@@ + +@@% @@@@@@@@@@ #@@@@@..-.-:=..#@@@@@:
+:@@@@@@@@@+ @@@@- @@@% @@@. .:@@@ #@@= . @@@ =@@*=. #@@::@@% . *@@+ @@@.. -@@% @@@@ %@@@@ #@@- . @@@ =@@# . @@@::@@@ . %@@+ @@@ +. @@% @@@@@@@@@@ #@@@@@%%%@%@@%@%@@@@@:
+:@@@@@@@@@+ @@@@@@@@@% @@@@@@@@@@ #@@@@@@@@@ =@@@@@@@@@::@@@@@@@@@+ @@@@@@@@@% @@@@@@@@@@ #@@@@@@@@@ =@@@@@@@@@::@@@@%@@@@+ @@@@@%@@@% @@@@@@@@@@ #@@@@@@@@@@@@@@@@@@@@:
+ .........  .........   .........  .........  .........  .........  .........   .........  .........  .........  .........  .........   .........  .................... 
+ %@@@@@@@@@@@@@# :@@@@@@@@%. %@@@@@@@@: #@@@@@@@@= *@@@@@@@@# =@@@@@@@@# :@@@@@@@@%. %@@@@@@@@: #@@@@@@@@= *@@@@@@@@# =@@@@@@@@# :@@@%@%@@%. %@@@@@@@@: #@@@@@@@@@@@@@@:
+:@@@@@@@@@@@@@@@ =@@=...+@@::@:##.+@.@+ @@#...:@@% @@#...:@@@ #@@....+@@ =@@=...=@@::@@-=@+:@@+ @@@@:*@@@% @@@-..-@@@ #@@:..:@@@ =@@#:*:%@@::@@@@=@@@@+ @@@@@@@@@@@@@@@:
+:@@@@==#-*-=@@@@ =@# %@* @@::@.:.  = @+ @@+ ==*@@% @@+ ++ *@@ #@@@= @@@@ =@@@%. %@@::@@.:@- @@+ @@@@ -@@@% @@: @@..@@ #@@ -# -@@ =@@ =@..@@::@@*: :+@@+ @@-=-+=:+-*:-@@:
+:@@@@*+:--:-@@@@ =@# *=- @@::@+  %  =@+ @@+ ==*@@% @@+ - :@@@ #@@@= @@@@ =@@* :@@@@::@@..@- @@+ @@@@ -@@@% @@. @@..@@ #@@  :-@@@ =@@ =@..@@::@@%# *%@@+ @@:---+-#.+::@@:
+:@@@@@@@@@@@@@@@ =@@*... %@::@@.:@- @@+ @@* ...@@% @@* @% +@@ #@@@+.@@@@ =@@. ..=@@::@@*...+@@+ @@@@.+@@@% @@@....%@@ #@@.+@@@@@ =@@. - =@@::@@@@@@@@@+ @@@@@@@@@@@@@@@:
+.@@@@@@@@@@@@@@@ -@@@@@@@@@:.@@@@@@@@@= @@@@@@@@@# @@@@@@@@@@ *@@@@@@@@@ -@@@@@@@@@:.@@@@@@@@@= @@@@@@@@@# @@@@@@@@@@ *@@@@@@@@@ -@@@#*%@@@:.@@@@@@@@@= @@@@@@@@@@@@@@@:
+ ...............     ......     ......     ......     ......      ......     ......     ......     ......     ......      ......     ......     ......    -@@@@@@@@@@@@:
+.@@@@@@@@@@@@@@@@@@ *@@@@@@@@@ =@@@@@@@@@:.@@@@@@@@@+ @@@@@@@@@# @@@@@@@@@@ *@@@@@@@@@ =@@@@@@@@@:.@@@@@@@@@+ @@@@@@@@@# @@@%++%@@@ *@@@+*#@@@ =@@@@@@@@@::@@@@@@@@@@@@:
+:@@@@@@@@@@@@@@@@@@ #@@@. %@@@ =@@- . *@@::@@. . =@@+ @@@ ..:@@% @@%... #@@ #@@ #@.-@@ =@@@@* @@@::@@::# :@@+ @@@ #@@@@% @@@=. -@@@ #@@@:.#@@@ =@@@-- %@@::@@@@@@@@@@@@:
+:@@@@-=#.=..-.%@@@@ #@@= : @@@ =@@- .:#@@::@@.:@+ @@+ @@@ ..+@@% @@:.@=:+@@ #@@ .. -@@ =@@@@* @@@::@@.  :@@@+ @@@ #@@@@% @@- #% :@@ #@@%  -@@@ =@@= . +@@::@@@@@@@@@@@@:
+:@@@@*=++=+@+=%@@@@ #@% .. =@@ =@@:-# :@@::@@..#: @@+ @@@ #@@@@% @@= *= .@@ #@@ #@ -@@ =@@-:= @@@::@@. # -@@+ @@@ +##@@% @@..@@. @@ #@@  . #@@ =@@-   +@@::@@@@@@@@@@@@:
+:@@@@@@@@@@@@@@@@@@ #@*=@@*-@@ =@@%::-@@@::@@+::=@@@+ @@@:%@@@@% @@@*::-%@@ #@@:%@=*@@ =@@%-:#@@@::@@++@#:%@+ @@@-::-@@% @@%....#@@ #@+ ##:.@@ =@@*+=%@@@::@@@@@@@@@@@@:
+ #################+ -########* .#########  #########: *########= +########+ -########* .#########  #########: *########= +########+ -########* .######### .############:
+ ::::::::::::  :::::::::  .::::::::  .::::::::.  ::::::::.  :::::::::  :::::::::  .::::::::  .::::::::.  ::::::::.  :::::::::  :::::::::  .:::::::::::::::::::::::::::: 
+:@@@@@@@@@@@@::@@@@@@@@@+ @@#%@@#@@% @@@#@@#@@@ #@@@%*@@@@ =@@#@@@#@@::@@%##%@@@+ @@%%@@#@@% @@%#@@#%@@ #@@@@@@@@@ =@@@@@@@@@::@@@@@@@@@+ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@:
+:@%*%@##***#@::@@@#-.+@@+ @@* *. @@% @@@ .. @@@ #@@ .: +@@ =@@ :@..@@::@@. : .@@+ @@: =# @@% @@. =+  @@ #@@@%#@@@@ =@@@@@@@@@::@@@%**@@@+ @@@@@@@@@@*##%##*#*%@@@@@@@@@:
+:@-::=::.#:%@::@@* .+@@@+ @@@* .@@@% @@@@  @@@@ #@* @@@@@@ =@@* * %@@::@@. . :@@+ @@:  : @@% @@..  . @@ #@@@*.@@@@ =@@@%.@@@@::@@@%-=@@@+ @@@@@@@@@@::.:..=@:@@@@@@@@@@:
+:@#+#@###@#@@::@@@@%-+@@+ @@@@ =@@@% @@% :: %@@ #@@ .- =@@ =@@@. :@@@::@@. -. @@+ @@:.%  @@% @@.-  - @@ #@@@%@@@@@ =@@@@@@@@@::@@@@@@@@@+ @@@@@@@@@@*##%##@@#@@@@@@@@@@:
+:@@@@@@@@@@@@::@@@@@@@@@+ @@@@#%@@@% @@%#@@#%@@ #@@@#*%@@@ =@@@@*@@@@::@@#**#@@@+ @@%#@@*@@% @@#%%#%#@@ #@@@@@@@@@ =@@@@@@@@@::@@@@@@@@@+ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@:
+ :----------:  :--------  :--------. .--------:  --------:  --------:  :--------  :--------. .--------:  --------:  --------:  :--------  :---------------------------: 
+ ************  ************  ************  ********************************************************************  ************  ************  ************  ************ 
+:@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@:
+:@@=+=+==+%@@::@@@=++=*-%@@::@@*+*==+=*@@::@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@::@@*+*==+=*@@::@@@=++=*-%@@::@@=+=+==+%@@::@@@@=*=*@@@@:
+:@*-++*..:=%@::@@*-::=%:@@@::@@-==..:=-@@::@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@::@@-==..:=-@@::@@*-::=%:@@@::@*-++*..:=%@::@@@@.#. @@@@:
+:@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@:
+.@@@@@@@@@@@@:.@@@@@@@@@@@@:.@@@@@@@@@@@@:.@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:.@@@@@@@@@@@@:.@@@@@@@@@@@@:.@@@@@@@@@@@@:.@@@@@@@@@@@@:
+ ::::::::::::  ::::::::::::  ::::::::::::  ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::  ::::::::::::  ::::::::::::  ::::::::::::  :::::::::::: 
 
+ +
- keys that move between German and U.S. layouts - deus + same keys, different board — watch Enter and left Shift change shape + isoansi
@@ -354,64 +403,73 @@

Stop fixing your layout by hand

From cb6c73a6f057ac1538306cbcfb06bfc71b9244a4 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Tue, 18 Aug 2026 22:48:10 +0200 Subject: [PATCH 6/6] style: give the hero headline room to breathe The headline sat flush against the keyboard - the measured gap was 0px, so the board and the type read as one crowded block. Adds a responsive gap below the terminal (70px on desktop, 44px on mobile) and rebalances the headline-to-tagline spacing that was doubling up underneath. --- docs/index.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/index.html b/docs/index.html index e2f029c..3becb05 100644 --- a/docs/index.html +++ b/docs/index.html @@ -51,6 +51,8 @@ .term { border:1px solid var(--line); border-radius:10px; background:var(--panel); overflow:hidden; box-shadow:0 24px 60px -30px #000; + /* the headline sat flush against the board; let the hero breathe */ + margin-bottom:clamp(2.75rem, 5.5vw, 4.75rem); } .term-bar { display:flex; align-items:center; gap:.5rem; padding:.6rem .85rem; @@ -81,7 +83,7 @@ .legend-row { display:flex; justify-content:center; gap:1.5rem; flex-wrap:wrap; color:var(--dim); font-size:.78rem; padding:0 1rem 1.4rem; } .legend-row span b { color:var(--orange); font-weight:400; } - .tagline { color:var(--dim); margin:2.2rem auto 1.6rem; max-width:62ch; font-size:.98rem; } + .tagline { color:var(--dim); margin:1.5rem auto 1.9rem; max-width:62ch; font-size:.98rem; } .tagline strong { color:var(--fg); font-weight:600; } h1 { font-size:clamp(1.5rem,3.6vw,2.15rem); letter-spacing:-0.02em; margin:0 0 .6rem; text-wrap:balance; } h1 .sub { display:block; color:var(--blue); }