diff --git a/app/patternfront.html b/app/patternfront.html
index d0facaf..f7db28a 100644
--- a/app/patternfront.html
+++ b/app/patternfront.html
@@ -27,9 +27,9 @@
--text:#c0c0c0; /* text */
--dim:#7d7d7d; /* tab_normal_text */
--mute:#636d79; /* status_bar_text */
- --accent:#2962ff; /* OpenFront's own blue, src/core/.../Colors.ts */
- --accent-hi:#4b8fff;
- --accent-ink:#ffffff;
+ --accent:#e1b85f; /* selected */
+ --accent-hi:#eec877; /* accent hover */
+ --accent-ink:#41444a; /* selected_text */
--link:#6e9adb; /* link_text */
--danger:#c75a68; /* flag_active */
--ok:#64c864; /* select_box_grid */
@@ -1774,17 +1774,17 @@
' map tiles. Repeats every '+(doc.w<OF_MAXW||doc.h>OF_MAXH){
$('ofData').textContent='—';
- $('ofStat').innerHTML='Canvas is '+doc.w+'×'+doc.h+
+ $('ofStat').innerHTML='Canvas is '+doc.w+'×'+doc.h+
'. OpenFront allows at most '+OF_MAXW+'×'+OF_MAXH+'.';
return null;}
const s=encodeOF(doc.w,doc.h,scale,to1bit());
$('ofData').textContent=s;
$('ofStat').innerHTML=s.length<=OF_MAXB64
- ?'Valid · '+s.length+' / '+OF_MAXB64+' characters'
- :'Too long — '+s.length+' / '+OF_MAXB64+'';
+ ?'Valid · '+s.length+' / '+OF_MAXB64+' characters'
+ :'Too long — '+s.length+' / '+OF_MAXB64+'';
return s;}
function copyText(t,ok){
- const done=()=>{$('ofStat').innerHTML=''+ok+'';};
+ const done=()=>{$('ofStat').innerHTML=''+ok+'';};
const fb=()=>{const ta=document.createElement('textarea');ta.value=t;
document.body.appendChild(ta);ta.select();
try{document.execCommand('copy');done();}
diff --git a/build/icon.png b/build/icon.png
index 2361919..5546518 100644
Binary files a/build/icon.png and b/build/icon.png differ
diff --git a/docs/09-design-system.md b/docs/09-design-system.md
index bbe67a5..a826456 100644
--- a/docs/09-design-system.md
+++ b/docs/09-design-system.md
@@ -34,6 +34,14 @@ Aseprite's own id is kept against each value so the mapping stays auditable.
Note Aseprite's dark accent is **gold**, not blue. Blue is reserved for links.
+This one was reversed once and reversed back. A revision moved `--accent` to
+OpenFront's own blue (`#2962ff`) on the reasoning that the tool is for OpenFront.
+The result was two blues eight hue-degrees apart doing unrelated jobs — selection
+and links — and a design document that disagreed with both the stylesheet and the
+checker. Chrome belongs to the audience, which lives in Aseprite; OpenFront fidelity
+is carried by the format, the codec and the map-scale preview, not by the accent.
+`tools/verify-ui.py` now fails on `#2962ff` so the round trip is not repeated.
+
## Hard rules
- **Radius 0–2px.** Nothing rounder, anywhere.
@@ -51,7 +59,8 @@ Two deliberate exceptions, both checked for:
## Interface scale
Aseprite's native density is very tight and it ships a **UI Scale** preference for
-exactly that reason, defaulting to 2× on high-DPI displays. PatternFront does the same.
+exactly that reason, defaulting to 2× on high-DPI displays. PatternFront carries the
+same preference at a lower default, for the reason given below.
Every length in the stylesheet is `rem`, and `html{font-size:calc(10px * var(--ui))}`
drives the lot. So `1.1rem` reads as "11px at 1×" and one token resizes the whole
@@ -59,18 +68,21 @@ interface. The artwork view scales too — `Z() = zoom * uiScale` — so the app
a single piece rather than the chrome growing around a fixed canvas.
The control sits in the top bar (1× · 1.5× · 2× · 2.5× · 3×) and persists to
-`localStorage`. **Default is 2×.**
+`localStorage`. **Default is 1.5×** — 2× was the original default and crowded the
+canvas once the preview took a column of its own.
+
+Columns are left / preview / right.
| Scale | Bars | Buttons | Font | Rail | Columns |
|---|---|---|---|---|---|
-| 1× | 24px | 18px | 11px | 26px | 152 / 190 |
-| 1.5× | 36px | 27px | 16px | 39px | 228 / 285 |
-| **2×** | **48px** | **36px** | **22px** | **52px** | **304 / 380** |
-| 2.5× | 60px | 45px | 28px | 65px | 380 / 475 |
-| 3× | 72px | 54px | 33px | 78px | 456 / 570 |
+| 1× | 24px | 18px | 11px | 26px | 152 / 240 / 190 |
+| **1.5×** | **36px** | **27px** | **16px** | **39px** | **228 / 360 / 285** |
+| 2× | 48px | 36px | 22px | 52px | 304 / 480 / 380 |
+| 2.5× | 60px | 45px | 28px | 65px | 380 / 600 / 475 |
+| 3× | 72px | 54px | 33px | 78px | 456 / 720 / 570 |
-Above 2× the side columns start to crowd the canvas on a 1440px window — `Tab` collapses
-both and is the intended companion to the larger scales.
+Above 1.5× the columns start to crowd the canvas on a 1440px window — `Tab` collapses
+them and is the intended companion to the larger scales.
## Density
@@ -90,16 +102,50 @@ Base values, scaled from Aseprite's own `dimensions` block, quoted at 1×.
```
top bar 24px
├ rail 26px · tools, always visible
-├ left column 152px · colour, palette, canvas, options
+├ left column 152px · colour, duotone, stamps, palette
├ stage · dominant, the only thing that grows
-└ right column 190px · preview, layers
+├ preview column 240px · preview and its controls
+└ right column 190px · layers
dock 24px · frames + status on one line
```
-`Tab` collapses both columns (`body.zen`) for a near-fullscreen canvas.
-One continuous docked column per side — no floating cards, no gaps, no rounding;
+The preview holds its own column rather than stacking under the layers panel. It is
+the surface a pattern is actually judged on — it is the only place the duotone
+appears at all — and sharing a column made it the first thing squeezed.
+
+`Tab` collapses all three columns and their gutters (`body.zen`) for a near-fullscreen
+canvas. Each boundary is a drag handle (`.gut`) that resizes the column it sits against
+and resets on double-click; the widths live in `--wL` / `--wP` / `--wR` so a drag and a
+scale change move the same numbers.
+
+One continuous docked column per region — no floating cards, no gaps, no rounding;
sections separated by 1px rules.
+## States
+
+Tokens and density were specified from the start; states were not, and drifted into
+inline hex. Every one below is a token, and `tools/verify-ui.py` fails on a literal
+colour used where one of these belongs.
+
+| State | Treatment | Token |
+|---|---|---|
+| Rest | `--raised` fill, `--edge-dim` border | — |
+| Hover | fill and border both go to `--edge` | `--edge` |
+| Active / pressed | fill drops to `--well` | `--well` |
+| Selected / on | `--accent` fill, `--accent-ink` text | `--accent`, `--accent-ink` |
+| Focus (keyboard) | 1px `--accent` outline, no offset | `--accent` |
+| Disabled | text to `--well`, default cursor, no hover | `--well` |
+| Success | text only, never a fill | `--ok` |
+| Error | text only, never a fill | `--danger` |
+
+Two rules that are easy to get wrong:
+
+- **Status is text, not chrome.** A valid or over-length pattern says so in `--ok` or
+ `--danger` text. It never tints a panel, because a coloured panel competes with the
+ artwork, which is the only thing in the window whose colour carries meaning.
+- **Focus is not hover.** Hover is a fill change; focus is an outline. A control that
+ showed focus by filling would be indistinguishable from selection.
+
## Anti-patterns
Every one of these was tried and rejected:
diff --git a/tools/gen-icon.py b/tools/gen-icon.py
index 1bb7e60..930a6fd 100644
--- a/tools/gen-icon.py
+++ b/tools/gen-icon.py
@@ -54,8 +54,10 @@
# The app's own chrome, so the icon and the window agree.
PAPER = (0x20, 0x21, 0x25) # --well, the editor's background
INK = (0xE6, 0xE6, 0xE8) # the editor's foreground
-DUO_A = (0x29, 0x62, 0xFF) # the accent, standing in for a duotone primary
-DUO_B = (0xF1, 0x7A, 0x3C) # its warm complement, the secondary
+ACCENT = (0xE1, 0xB8, 0x5F) # --accent, the same gold the app selects with
+# Inside the band only the *ink* changes colour; the ground stays the same dark as
+# everywhere else. Giving the band its own ground introduced a third grey that sat
+# between paper and ink and made the whole tile muddy at small sizes.
# A diagonal, which is one of the patterns the editor ships, at the coarsest
# step that still reads as motion. Two cells of ink, two of paper, shifted one
@@ -133,10 +135,10 @@ def pixel(x: int, y: int):
cy = min(GRID - 1, max(0, int((y - inset) // cell)))
lit = ART[cy][cx] == "#"
- if in_band(cx, cy):
- rgb = DUO_B if lit else DUO_A
+ if lit:
+ rgb = ACCENT if in_band(cx, cy) else INK
else:
- rgb = INK if lit else PAPER
+ rgb = PAPER
return (rgb[0], rgb[1], rgb[2], round(a * 255))
diff --git a/tools/verify-ui.py b/tools/verify-ui.py
index f249e42..046a3c5 100644
--- a/tools/verify-ui.py
+++ b/tools/verify-ui.py
@@ -37,7 +37,9 @@
"--text": "#c0c0c0", # text
"--dim": "#7d7d7d", # tab_normal_text
"--mute": "#636d79", # status_bar_text
- "--accent": "#2962ff", # OpenFront's own blue
+ "--accent": "#e1b85f", # selected
+ "--accent-hi": "#eec877", # accent hover
+ "--accent-ink": "#41444a", # selected_text
"--link": "#6e9adb", # link_text
"--danger": "#c75a68", # flag_active
}
@@ -45,7 +47,7 @@
# The accent is exempt: Aseprite's own `selected` is a bright gold, and
# selection is meant to be loud. The rule is about chrome, not highlights.
MAX_SURFACE_LUMA = 0.30
-ACCENT_FAMILY = {"2962ff", "4b8fff"}
+ACCENT_FAMILY = {"e1b85f", "eec877"}
fails: list[str] = []
@@ -200,7 +202,28 @@ def main() -> int:
chk("picking a pair yields exactly two colours",
"doc.palette=[{r:0,g:0,b:0,a:0},hexRGB(prim),hexRGB(sec)]" in js)
- chk("accent is no longer gold", "#e1b85f" not in css)
+ # Aseprite's dark accent is gold and blue is reserved for links, which is what
+ # keeps the two distinguishable. An earlier revision moved the accent to
+ # OpenFront's blue (#2962ff), leaving two near-identical blues doing different
+ # jobs. The tool's chrome belongs to the audience that lives in Aseprite;
+ # OpenFront fidelity is carried by the format and the preview, not the chrome.
+ chk("accent is gold, not OpenFront blue",
+ "#2962ff" not in css and "#4b8fff" not in css)
+
+ # States were the part of the system nobody wrote down, so they drifted into
+ # inline hex: four `style="color:#e5674b"` strings sat beside a --danger token
+ # that already held that job. See docs/09-design-system.md, "States".
+ print("\n=== states ===")
+ chk("status colour comes from tokens, never inline hex",
+ not re.search(r'style="color:#[0-9a-fA-F]', js), )
+ chk("focus is an outline in the accent, not a fill",
+ "outline:0.1rem solid var(--accent)" in css)
+ chk("hover changes fill and border to --edge",
+ ".b:hover:not(:disabled){background:var(--edge);border-color:var(--edge)}" in css)
+ chk("pressed drops to --well", ".b:active:not(:disabled){background:var(--well)}" in css)
+ chk("disabled dims to --well", ".b:disabled{color:var(--well)" in css)
+ chk("selected is accent fill with accent ink",
+ 'background:var(--accent);color:var(--accent-ink)' in css)
print("\n=== preview & colour input ===")
chk("preview starts fitted and tiled", "pvScale=0,pvTile=true" in js)