diff --git a/docs/changes/unreleased/1509-nav-air-row.md b/docs/changes/unreleased/1509-nav-air-row.md new file mode 100644 index 000000000..6cee10de2 --- /dev/null +++ b/docs/changes/unreleased/1509-nav-air-row.md @@ -0,0 +1,12 @@ +--- +kind: changed +title: tui3 head grows an air row under the top navigation line +pr: 1509 +surface: [chat] +invalidates: + - "The tui3 page head was four rows under the top navigation line. It is five: a blank air row sits directly under the nav line, then the tab strip, divider, and margin row." +--- + +Every page's head pays one extra row for the air row; the welcome screen's +budget and the tui3 tests were adjusted with it. Visual proof lives at +`docs/design/spark-header-spacing.png` (live Spark tmux capture). diff --git a/docs/design/header-spacing-ref-flux.png b/docs/design/header-spacing-ref-flux.png new file mode 100644 index 000000000..c51b1f21f Binary files /dev/null and b/docs/design/header-spacing-ref-flux.png differ diff --git a/docs/design/header-spacing-ref-imagen.png b/docs/design/header-spacing-ref-imagen.png new file mode 100644 index 000000000..07399bfec Binary files /dev/null and b/docs/design/header-spacing-ref-imagen.png differ diff --git a/docs/design/header-spacing-ref-imagen2.png b/docs/design/header-spacing-ref-imagen2.png new file mode 100644 index 000000000..269df1028 Binary files /dev/null and b/docs/design/header-spacing-ref-imagen2.png differ diff --git a/docs/design/header-spacing-reference.png b/docs/design/header-spacing-reference.png new file mode 100644 index 000000000..6512f838c Binary files /dev/null and b/docs/design/header-spacing-reference.png differ diff --git a/docs/design/spark-header-spacing.png b/docs/design/spark-header-spacing.png new file mode 100644 index 000000000..df045abcc Binary files /dev/null and b/docs/design/spark-header-spacing.png differ diff --git a/docs/design/spark-tmux-live-header.png b/docs/design/spark-tmux-live-header.png new file mode 100644 index 000000000..5f4654bf9 Binary files /dev/null and b/docs/design/spark-tmux-live-header.png differ diff --git a/internal/tui3/chattabs.go b/internal/tui3/chattabs.go index 737781a42..0d68769dc 100644 --- a/internal/tui3/chattabs.go +++ b/internal/tui3/chattabs.go @@ -487,10 +487,10 @@ func tabsCapped(tabs []chatTab, prev []string) []chatTab { // those numbers. // tabsHeight is what the head costs the body region DOWN TO AND INCLUDING THE -// STRIP when a conversation is in front: the pulse, and the strip under it -// ([tabStripRow]). On a place the strip is not drawn, so this is the nav's -// one row and the page owns the row under it (head.go's [app.stripInHead]). -// +// STRIP when a conversation is in front: the pulse, the air row under it, and +// the strip ([tabStripRow]). On a place the strip is not drawn, so this is the +// nav and its air row, and the page owns the row under them (head.go's +// [app.stripInHead]).// // IT STANDS DOWN ON THE TWO FLOORS THE CONVERSATION'S BAR STOOD DOWN ON. A frame // too narrow for a name and a way out is too narrow for this, and a terminal too // short for a blank above the draft has no row to spare for a fact that is true @@ -509,7 +509,7 @@ func (a *app) tabsHeight(width int) int { return 0 } if !a.stripInHead() { - return navRow + 1 + return navAirRow + 1 } return tabStripRow + 1 } diff --git a/internal/tui3/head.go b/internal/tui3/head.go index 436e1a7dc..e4f3a126d 100644 --- a/internal/tui3/head.go +++ b/internal/tui3/head.go @@ -3,22 +3,24 @@ package tui3 // THE HEAD: ONE SET OF ROWS OVER EVERY FRAME. // // >● codeaf home teams chats sessions spend settings 2 want you · $1.20 thu 10:31pm +// // ● harbor ▾ ◆ Manager × Refactor the rail… × + ▦ All // ─────────────────────────────────────────────────────────────────────────────────────────── // // The wordmark with the places after it and the machine's pulse on the far -// end (topnav.go) is row zero on every page, and it never moves. The strip of -// chats (chattabs.go) is the next row ONLY while a conversation is in front: -// a chat, a room inside one, the grid of open tabs, the run's work tab. On a -// place the strip is not drawn, so the head is the nav, the rule and a blank -// ([placeHeadRows]) and the body starts one row higher. In a chat the strip -// sits between the nav and the rule ([chatHeadRows]). +// end (topnav.go) is row zero on every page, and it never moves. Under it +// sits an air row ([navAirRow]): the breathing room that keeps the places' +// words from pressing on the strip and the rule. The strip of chats +// (chattabs.go) is the next row ONLY while a conversation is in front: a +// chat, a room inside one, the grid of open tabs, the run's work tab. On a +// place the strip is not drawn, so the head is the nav, its air row, the rule +// and a blank ([placeHeadRows]) and the body starts one row higher. In a chat +// the strip sits between the air row and the rule ([chatHeadRows]). // // THE STRIP USED TO BE DRAWN ON EVERY PAGE, including places, where it repeated // the teams rail and home's sessions and offered a jump from a place to one // chat that is not a journey anyone takes. `chats` on row zero, `alt+k` and -// home's sessions list are the ways between them (owner, 2026-09-25). -// +// home's sessions list are the ways between them (owner, 2026-09-25).// // THE PLACES AND THE STRIP USED TO SHARE ROW ONE AND TAKE TURNS ON IT. A place // drew its bar there and a conversation drew its strip there, so the words a // hand was reaching for changed under it on every walk (owner, 2026-09-24). @@ -35,9 +37,10 @@ package tui3 // under it, where a place's heading is (PLACES-AUDIT.md, lane K). // headRows is the head, drawn at `width` in `pal`. While a conversation is in -// front, `strip` (the frame's [app.tabsRow]) is the second row and the head is -// [chatHeadRows] rows. On a place `strip` is ignored and the head is -// [placeHeadRows]: the nav, the rule, a blank. A frame that draws fewer, a +// front, `strip` (the frame's [app.tabsRow]) is the row under the air row and +// the head is [chatHeadRows] rows. On a place `strip` is ignored and the head +// is [placeHeadRows]: the nav, its air row, the rule, a blank. A frame that +// draws fewer, a // terminal under the strip's floors, takes a prefix of it, so the rows a frame // draws and the rows it charges are one count. // @@ -69,9 +72,9 @@ func (a *app) headRows(width int, strip string, pal palette) []string { if !a.stripInHead() { a.chatTabHits = nil a.wall.chip, a.wall.door = hudSpan{}, hudSpan{} - return []string{nav, line, ""} + return []string{nav, "", line, ""} } - return []string{nav, strip, line, ""} + return []string{nav, "", strip, line, ""} } // stripInHead reports whether this frame draws the chat strip. A conversation diff --git a/internal/tui3/head_test.go b/internal/tui3/head_test.go index c88d49564..4bf3ee9c6 100644 --- a/internal/tui3/head_test.go +++ b/internal/tui3/head_test.go @@ -11,10 +11,9 @@ import ( // ── THE ONE HEAD ──────────────────────────────────────────────────────────── // // Row zero is the same on a conversation and on every place. A conversation -// then draws the strip, the rule and a blank ([chatHeadRows]). A place draws -// the rule and a blank and no strip ([placeHeadRows]), so its body starts one -// row higher. - +// then draws the air row, the strip, the rule and a blank ([chatHeadRows]). A +// place draws the air row, the rule and a blank and no strip +// ([placeHeadRows]), so its body starts one row higher. // headSizes are the three frames the one head is pinned at: the classic // terminal, a tall one, and a wide one. var headSizes = []struct{ w, h int }{{80, 24}, {80, 40}, {120, 45}} @@ -65,11 +64,11 @@ func headPulseWhole() string { return "2 want you · 1 moving · $0.14 / " + railFigure(20) + " · thu 9:49am" } -// THE CONVERSATION'S HEAD IS THE NAV, THE STRIP, THE RULE AND A BLANK. -// The body starts under exactly those four rows. At eighty columns the pulse -// has given up the clock, the moving count and the words of `2 want you`, whose -// count stays as `2 ?`, to keep every place on the row (topnav.go's ladder); -// wider, it says everything. +// THE CONVERSATION'S HEAD IS THE NAV, THE AIR ROW, THE STRIP, THE RULE AND A +// BLANK. The body starts under exactly those five rows. At eighty columns the +// pulse has given up the clock, the moving count and the words of `2 want you`, +// whose count stays as `2 ?`, to keep every place on the row (topnav.go's +// ladder); wider, it says everything. func TestTheConversationWearsThePlacesHead(t *testing.T) { a := headLab(t) for _, size := range headSizes { @@ -89,8 +88,8 @@ func TestTheConversationWearsThePlacesHead(t *testing.T) { if strings.Contains(head[tabStripRow], " home ") || !strings.Contains(head[tabStripRow], a.chatDisplayName()) { t.Fatalf("at %dx%d the strip is not under the nav, on its own:\n%q", size.w, size.h, head[tabStripRow]) } - if head[2] != strings.Repeat("─", size.w) || head[3] != "" { - t.Fatalf("at %dx%d the head does not close with a rule and a blank:\n%q\n%q", size.w, size.h, head[2], head[3]) + if head[tabStripRow+1] != strings.Repeat("─", size.w) || head[tabStripRow+2] != "" { + t.Fatalf("at %dx%d the head does not close with a rule and a blank:\n%q\n%q", size.w, size.h, head[tabStripRow+1], head[tabStripRow+2]) } if a.headHeight() != chatHeadRows || a.bodyTop() != chatHeadRows+a.stripHeight() { t.Fatalf("at %dx%d the head draws %d rows and is charged %d, body at %d", @@ -108,10 +107,11 @@ func TestTheConversationWearsThePlacesHead(t *testing.T) { // (roompanel.go's [app.roomOrganized]). var headFrameSizes = []struct{ w, h int }{{80, 24}, {120, 45}, {180, 45}} -// A TASK ROOM SPENDS THE CONVERSATION'S HEAD ABOVE ITS BODY: the nav, the -// strip, the rule and a blank, and the room's own trail on the first row under -// them. A place spends three rows and no strip, so its body starts where the -// chat's strip is. The room used to lay the trail where the rule stands +// A TASK ROOM SPENDS THE CONVERSATION'S HEAD ABOVE ITS BODY: the nav, the air +// row, the strip, the rule and a blank, and the room's own trail on the first +// row under them. A place spends four rows and no strip, so its body starts +// one row above a conversation's. The room used to lay the trail where the +// rule stands // (PLACES-AUDIT.md, lane K). func TestATaskRoomSpendsThePlacesHeadAboveItsBody(t *testing.T) { room, chat := crumbApp(t), headLab(t) @@ -127,13 +127,13 @@ func TestATaskRoomSpendsThePlacesHeadAboveItsBody(t *testing.T) { } f.a.touch() head := headOf(t, f.a) - ruleAt := 2 + ruleAt := tabStripRow + 1 if f.to != pageNone { - ruleAt = 1 + ruleAt = placeHeadRows - 2 } if !strings.HasPrefix(head[0], " "+plain(f.a.pal.wordmark(f.a.width))) || head[ruleAt] != strings.Repeat("─", size.w) || strings.TrimSpace(head[ruleAt+1]) != "" { t.Fatalf("at %dx%d %s's head is not the nav, then %s:\n%s", - size.w, size.h, f.where, map[bool]string{true: "the rule and a blank", false: "the strip, the rule and a blank"}[f.to != pageNone], strings.Join(head, "\n")) + size.w, size.h, f.where, map[bool]string{true: "the air row, the rule and a blank", false: "the air row, the strip, the rule and a blank"}[f.to != pageNone], strings.Join(head, "\n")) } if f.to != pageNone { f.a.showPage(pageNone) @@ -242,7 +242,7 @@ func TestThePulseOverAChatIsThePulseOverAPlace(t *testing.T) { if !strings.Contains(place[navRow], "home") || !strings.Contains(place[navRow], "sessions") { t.Fatalf("the nav is not on the first row: %q", place[navRow]) } - if strings.Contains(place[tabStripRow], a.chatDisplayName()) || !strings.HasPrefix(place[1], "─") { + if strings.Contains(place[tabStripRow], a.chatDisplayName()) || !strings.HasPrefix(place[tabStripRow], "─") { t.Fatalf("the tasks place drew a strip under the nav: %q", place[tabStripRow]) } a.showPage(pageNone) diff --git a/internal/tui3/header_home_test.go b/internal/tui3/header_home_test.go index 927102254..ac1008ef5 100644 --- a/internal/tui3/header_home_test.go +++ b/internal/tui3/header_home_test.go @@ -86,7 +86,7 @@ func TestTheHeadAroundTheStripIsInertAndHomeHasPlainHover(t *testing.T) { // THE STRIP KEEPS ITS ACTIVE TAB AND THE HEAD KEEPS ITS SHAPE AT EVERY SIZE. The // head used to grow a row of air over the strip at thirty-two rows and another -// under it at thirty-six; it is the places' four rows now wherever the strip is +// under it at thirty-six; it is the places' five rows now wherever the strip is // drawn at all, and nothing below the strip's own floors. func TestTheStripKeepsItsActiveTabAndTheHeadItsShapeAtEverySize(t *testing.T) { lab := newStartLab(t) diff --git a/internal/tui3/margin_test.go b/internal/tui3/margin_test.go index b158853ac..6f0766bdb 100644 --- a/internal/tui3/margin_test.go +++ b/internal/tui3/margin_test.go @@ -24,7 +24,7 @@ import ( func marginApp(t *testing.T, stand ...standing.Item) (*app, *standingPlaceFake) { t.Helper() a, agent := standingPlaceApp(t, stand, nil) - a.width, a.height = 140, 24 + a.width, a.height = 140, 25 // the head's air row costs the frame one row return a, agent } @@ -307,7 +307,7 @@ func TestBareStandingStillOpensThePage(t *testing.T) { // chord answers with (standmark.go). func TestStandingWithWordsSaysSoWhereNothingCanHoldOne(t *testing.T) { a := newTestApp(&fakeAgent{model: "m"}) - a.width, a.height = 140, 24 + a.width, a.height = 140, 25 // the head's air row costs the frame one row typeLine(t, a, "/standing always run the tests") if !strings.Contains(plain(frame(a)), standMarkNowhere) { t.Fatalf("the refusal is not on the frame:\n%s", plain(frame(a))) diff --git a/internal/tui3/onetopbar_test.go b/internal/tui3/onetopbar_test.go index d47682dbe..79ad501f6 100644 --- a/internal/tui3/onetopbar_test.go +++ b/internal/tui3/onetopbar_test.go @@ -60,8 +60,8 @@ func TestOneTopNavOnAChatAndOnAPlace(t *testing.T) { t.Fatalf("at %d the place drew the strip on row %d", width, py2) } placeRows := strings.Split(plain(frame), "\n") - if len(placeRows) <= placeHeadRows || !strings.HasPrefix(placeRows[1], "─") || strings.TrimSpace(placeRows[2]) != "" { - t.Fatalf("at %d the place's head is not the nav, the rule and a blank", width) + if len(placeRows) <= placeHeadRows || !strings.HasPrefix(placeRows[placeHeadRows-2], "─") || strings.TrimSpace(placeRows[placeHeadRows-1]) != "" { + t.Fatalf("at %d the place's head is not the nav, the air row, the rule and a blank", width) } lit := a.pal.onPlaces() if !strings.Contains(frame, lit.bold(lit.accent(tabPad+"spend"+tabPad))) { diff --git a/internal/tui3/placebodies.go b/internal/tui3/placebodies.go index 8f8506cb6..cc1df907c 100644 --- a/internal/tui3/placebodies.go +++ b/internal/tui3/placebodies.go @@ -37,21 +37,21 @@ import ( // around them and the keyboard, which is the whole of what that wave claimed. // placeHeadRows is how many rows every place spends before its body: the nav, -// the rule, and the blank under it (pages.go's [placeFrame]). +// the air row under it, the rule, and the blank under that (pages.go's +// [placeFrame]). // // THE STRIP IS NOT ONE OF THEM. It is a chat's own row, drawn only while a // conversation is in front (head.go), so a place's body starts one row higher -// than a chat's and a click on that row is the page's. -// +// than a chat's and a click on that row is the page's.// // IT IS A CONSTANT AND THE POINTER DEPENDS ON IT. A press arrives as a row of // the terminal and has to become a row of the body, and the only honest way to // subtract the head is to have exactly one number for how tall the head is. // A head row added or removed is a change to this constant and to nothing else. -const placeHeadRows = 3 +const placeHeadRows = 4 -// chatHeadRows is the head while a conversation is in front: the places' three -// rows with the strip between the nav and the rule. A room inside a chat wears -// it too. A place does not. +// chatHeadRows is the head while a conversation is in front: the places' four +// rows with the strip between the air row and the rule. A room inside a chat +// wears it too. A place does not. const chatHeadRows = placeHeadRows + 1 // placeNote is the one line a place says about what it is holding, drawn under diff --git a/internal/tui3/placelaws_test.go b/internal/tui3/placelaws_test.go index 06a5aa4f2..7989044ba 100644 --- a/internal/tui3/placelaws_test.go +++ b/internal/tui3/placelaws_test.go @@ -322,8 +322,7 @@ func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) { // doors rather than counted out again here. wantFor := func(id page, size [2]int) edges { height := size[1] - got := edges{bar: navRow, headRule: 1, blank: placeHeadRows - 1, - footRule: height - placeFootRowsFor(id, height) + 1, box: -1, hint: height - 1} + got := edges{bar: navRow, headRule: placeHeadRows - 2, blank: placeHeadRows - 1, footRule: height - placeFootRowsFor(id, height) + 1, box: -1, hint: height - 1} if id == pageHome { got.box = height - 1 - boxFloor(height) } @@ -339,8 +338,8 @@ func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) { rows[i] = ansi.Strip(line) } got := edges{bar: a.tabRow, headRule: -1, blank: -1, footRule: -1, box: -1, hint: len(rows) - 1} - if len(rows) > 1 && strings.HasPrefix(rows[1], "──") { - got.headRule = 1 + if len(rows) > 2 && strings.HasPrefix(rows[2], "──") { + got.headRule = 2 } if strings.TrimSpace(rows[placeHeadRows-1]) == "" { got.blank = placeHeadRows - 1 diff --git a/internal/tui3/topnav.go b/internal/tui3/topnav.go index 7c9337d57..9d1a5c9c1 100644 --- a/internal/tui3/topnav.go +++ b/internal/tui3/topnav.go @@ -65,10 +65,14 @@ import ( // frame with no head at all. const navRow = 0 -// tabStripRow is the row the chat strip is drawn on: under the nav and over -// the rule, and only while a conversation is in front (head.go). -const tabStripRow = 1 +// navAirRow is the row of air under the nav, on every frame that draws a +// head: the spacing between the places' row and the strip or rule under it +// (head.go). +const navAirRow = 1 +// tabStripRow is the row the chat strip is drawn on: under the nav's air row +// and over the rule, and only while a conversation is in front (head.go). +const tabStripRow = navAirRow + 1 const ( // navInset is the blank cell at each end of the row, the same inset every // row of the head keeps. diff --git a/internal/tui3/topnav_test.go b/internal/tui3/topnav_test.go index b61923799..1a08e4c56 100644 --- a/internal/tui3/topnav_test.go +++ b/internal/tui3/topnav_test.go @@ -57,10 +57,11 @@ func headRowsOf(a *app) []string { return rows } -// ROW ZERO IS THE SAME ON EVERY PAGE. A chat then draws the strip, the rule -// and a blank, and the body under those four rows. A place draws the rule and -// a blank and no strip, and the body starts on the next row. -func TestTheHeadIsTheSameFourRowsOnEveryPage(t *testing.T) { +// ROW ZERO IS THE SAME ON EVERY PAGE. A chat then draws the air row, the +// strip, the rule and a blank, and the body under those five rows. A place +// draws the air row, the rule and a blank and no strip, and the body starts on +// the next row. +func TestRowZeroIsTheSameOnEveryPage(t *testing.T) { for _, width := range navWidths { a := navChat(t) a.width = width @@ -70,7 +71,7 @@ func TestTheHeadIsTheSameFourRowsOnEveryPage(t *testing.T) { if a.tabRow != navRow || a.headHeight() != chatHeadRows { t.Fatalf("at %d the chat's nav is on row %d and its head is %d rows", width, a.tabRow, a.headHeight()) } - if !strings.Contains(chat[tabStripRow], "harbor") || !strings.HasPrefix(chat[2], "─") || strings.TrimSpace(chat[3]) != "" { + if !strings.Contains(chat[tabStripRow], "harbor") || !strings.HasPrefix(chat[tabStripRow+1], "─") || strings.TrimSpace(chat[tabStripRow+2]) != "" { t.Fatalf("at %d the chat's head is not the nav, the strip, the rule and a blank:\n%s", width, strings.Join(chat, "\n")) } for _, to := range []page{pageHome, pageTeams, pageSpend} { @@ -79,10 +80,10 @@ func TestTheHeadIsTheSameFourRowsOnEveryPage(t *testing.T) { if a.tabRow != navRow || a.headHeight() != placeHeadRows { t.Fatalf("at %d %s drew its nav on row %d and a %d-row head", width, to.word(), a.tabRow, a.headHeight()) } - if place[navRow] != chat[navRow] { - t.Fatalf("at %d %s moved row zero:\nchat %q\nplace %q", width, to.word(), chat[navRow], place[navRow]) + if place[navRow] != chat[navRow] || place[1] != chat[1] { + t.Fatalf("at %d %s moved the nav or the air row under it:\nchat %q\nplace %q", width, to.word(), chat[navRow], place[navRow]) } - if !strings.HasPrefix(place[1], "─") || strings.TrimSpace(place[2]) != "" || strings.Contains(place[1], "harbor") { + if !strings.HasPrefix(place[placeHeadRows-2], "─") || strings.TrimSpace(place[placeHeadRows-1]) != "" || strings.Contains(place[placeHeadRows-2], "harbor") { t.Fatalf("at %d %s drew a strip where the rule belongs:\n%s", width, to.word(), strings.Join(place, "\n")) } if len(a.chatTabHits) != 0 { @@ -221,9 +222,10 @@ func TestAPressOnEachNavWordOpensThatPlace(t *testing.T) { } } -// A PRESS ON ROW 1 OF A PLACE IS THE PAGE'S. The strip is not drawn there, so -// the row under the nav is the rule, and a click on it does not open a chat. -// The page's own first row is the next one after the blank. +// A PRESS ON THE STRIP'S ROW OF A PLACE IS THE PAGE'S. The strip is not drawn +// on a place, so the row where a conversation would draw it holds the rule, +// and a click on it does not open a chat. The page's own first row is the one +// after the head's blank. func TestAPressOnRowOneOfAPlaceIsThePages(t *testing.T) { a := navChat(t) a.width = 160 @@ -238,11 +240,11 @@ func TestAPressOnRowOneOfAPlaceIsThePages(t *testing.T) { t.Fatalf("%s kept strip targets", id.word()) } if _, ok := a.tabAt(4, tabStripRow); ok { - t.Fatalf("%s still has a tab on row 1", id.word()) + t.Fatalf("%s still has a tab on the strip's row", id.word()) } rows := strings.Split(plain(frame(a)), "\n") - if len(rows) <= placeHeadRows || !strings.HasPrefix(rows[1], "─") { - t.Fatalf("%s row 1 is not the rule:\n%s", id.word(), strings.Join(rows[:placeHeadRows+1], "\n")) + if len(rows) <= placeHeadRows || !strings.HasPrefix(rows[placeHeadRows-2], "─") { + t.Fatalf("%s the strip's row is not the rule:\n%s", id.word(), strings.Join(rows[:placeHeadRows+1], "\n")) } drive(t, a, tea.MouseClickMsg{X: 4, Y: tabStripRow, Button: tea.MouseLeft}) if !a.at(id) || a.frontTabKey() != front { diff --git a/internal/tui3/welcome.go b/internal/tui3/welcome.go index 3f91ffe5e..d616382a9 100644 --- a/internal/tui3/welcome.go +++ b/internal/tui3/welcome.go @@ -791,7 +791,7 @@ const welcomeUnitWidth = 76 // to type — so a small window simply opens on the prompt, which is what it // would have done anyway. const ( - welcomeMinRows = 12 + welcomeMinRows = 11 // the head's air row is paid out of the greeting's reserve welcomeMinCols = 40 ) diff --git a/scripts/gen-header-ref-imagen2.py b/scripts/gen-header-ref-imagen2.py new file mode 100644 index 000000000..930a26a0d --- /dev/null +++ b/scripts/gen-header-ref-imagen2.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Generate docs/design/header-spacing-ref-imagen2.png via Google Vertex AI imagen. + +Tries model literal name "imagen"; on 400, reads the rejection's accepted model +values and retries once with the closest same-family model. +""" +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +from google import genai + +PROMPT = ( + 'A pixel-sharp screenshot of a dark terminal application, perfectly flat ' + 'solid background color with NO texture, NO gradient, NO photographic noise, ' + 'rendered typography with hard edges like a real terminal screenshot. Four ' + 'header text rows: (1) nav line: codeaf home teams chats sessions spend ' + 'settings, right-aligned status at the edge; (2) one entire empty row of pure ' + 'background, zero characters; (3) chat tab strip: harbor Manager Refactor the ' + 'rail plus plus; (4) a full-width horizontal rule of dash glyphs. The empty ' + 'second row must be a flat clean band separating nav from strip. Below, dim ' + 'gray placeholder prose on the same flat background. Flat vector-like ' + 'precision, screenshot aesthetic.' +) + +OUT = Path("docs/design/header-spacing-ref-imagen2.png") + + +def access_token() -> str: + """Vertex needs OAuth; prefer ADC, fall back to gcloud user token.""" + env = dict(os.environ) + env.pop("GOOGLE_APPLICATION_CREDENTIALS", None) + try: + from google.auth import default + creds, _ = default() + creds.refresh(__import__("google.auth.transport.requests", fromlist=["Request"]).Request()) + return creds.token + except Exception: + tok = subprocess.run( + ["gcloud", "auth", "application-default", "print-access-token"], + capture_output=True, text=True, + ).stdout.strip() + if tok: + return tok + tok = subprocess.run( + ["gcloud", "auth", "print-access-token"], capture_output=True, text=True, + ).stdout.strip() + if tok: + return tok + raise + + +def accepted_models(err) -> list: + try: + m = re.search(r"[a-z0-9-]*(?:imagen|image)[a-z0-9-]*", str(err), re.I) + return [m.group(0)] if m else [] + except Exception: + return [] + + +def generate(client, model: str): + resp = client.models.generate_images( + model=model, + prompt=PROMPT, + config={"aspect_ratio": "2:1"}, + ) + if not resp.generated_images: + raise SystemExit(f"model '{model}': no generated images") + OUT.write_bytes(resp.generated_images[0].image.image_bytes) + print(json.dumps({"model": model, "path": str(OUT), "size": OUT.stat().st_size})) + + +def main(): + token = access_token() # OAuth2 token from gcloud user login + from google.auth.credentials import Credentials as _BaseCreds + + class _TokenCreds(_BaseCreds): + def __init__(self, tok): + self.token = tok + self.expired = False + self.valid = True + self.quota_project_id = None + + def refresh(self, request): + pass + + creds = _TokenCreds(token) + client = genai.Client( + vertexai=True, + project="model-development-504013", + location="us-central1", + credentials=creds, + ) + + first = "imagen" + try: + generate(client, first) + return + except Exception as e: + print(f"model '{first}' REJECTED: {type(e).__name__}: {e}", file=sys.stderr) + if "400" not in str(e): + raise + models = accepted_models(e) + if not models: + # one last probe: ask the API for the accepted list and parse it + try: + body = getattr(getattr(e, "_response", None), "text", "") or str(e) + models = re.findall(r"[a-z0-9-]*(?:imagen|image)[a-z0-9-]*", body) + except Exception: + models = [] + if not models: + raise SystemExit(f"could not parse accepted models from 400: {e}") + second = models[-1] if "imagen" in models[-1] else models[0] + print(f"retrying once with '{second}'", file=sys.stderr) + try: + generate(client, second) + except Exception as e2: + raise SystemExit(f"second model REJECTED: {type(e2).__name__}: {e2}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/gen-header-ref-img.py b/scripts/gen-header-ref-img.py new file mode 100644 index 000000000..454c7b803 --- /dev/null +++ b/scripts/gen-header-ref-img.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Generate docs/design/header-spacing-ref-gpt-image.png via OpenAI images API.""" +import json +import os +import sys +from pathlib import Path + +from openai import OpenAI + +PROMPT = ( + 'Flat 2D mockup of a dark terminal UI screenshot in crisp monospace type on a ' + 'near-black background. Exactly four header rows at the top: first row a top nav ' + 'reading "● codeaf home teams chats sessions spend settings" with a ' + 'right-aligned status "2 want you · $1.20 thu 10:31pm"; second row COMPLETELY ' + 'BLANK, one full empty text line; third row a chat strip "● harbor ▾ ◆ Manager × ' + 'Refactor the rail… × + ▦ All"; fourth row a full-width rule of dash glyphs. ' + 'Beneath the rule, dim gray placeholder prose. Precise, aligned, screenshot-flat, ' + 'no photorealism, no shadows, no garbled glyphs.' +) + +OUT = Path("docs/design/header-spacing-ref-gpt-image.png") +OUT.parent.mkdir(parents=True, exist_ok=True) + +client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) + +# First attempt with the literal requested model name. +model = "gpt-image" +resp = None +try: + resp = client.images.generate( + model=model, + prompt=PROMPT, + size="1792x896", # ~2:1 + n=1, + ) + print(f"model '{model}' accepted") +except Exception as e: + print(f"model '{model}' REJECTED: {type(e).__name__}: {e}", file=sys.stderr) + body = getattr(getattr(e, "response", None), "text", "") + print(f"RESPONSE BODY:\n{body}", file=sys.stderr) + if "400" not in str(e) and "400" not in str(getattr(getattr(e, "response", None), "status_code", "")): + raise SystemExit(1) + # 400 error: retry once with closest model of same family per task instructions. + model = "gpt-image-1" + print(f"retrying once with '{model}'", file=sys.stderr) + try: + resp = client.images.generate( + model=model, + prompt=PROMPT, + size="1792x896", + n=1, + ) + print(f"model '{model}' accepted") + except Exception as e2: + body2 = getattr(getattr(e2, "response", None), "text", "") + print(f"second model REJECTED: {type(e2).__name__}: {e2}\n{body2}", file=sys.stderr) + raise SystemExit(1) + +if not resp or not resp.data: + raise SystemExit("no image data in response") + +data = resp.data[0] +if data.b64_json: + import base64 + OUT.write_bytes(base64.b64decode(data.b64_json)) + print(f"wrote b64 -> {OUT} ({OUT.stat().st_size} bytes)") +elif data.url: + import urllib.request + urllib.request.urlretrieve(data.url, OUT) + print(f"downloaded -> {OUT} ({OUT.stat().st_size} bytes)") +else: + raise SystemExit("no b64_json and no url in response") +print(json.dumps({"model": model, "path": str(OUT), "size": OUT.stat().st_size})) \ No newline at end of file