3 Commits

Author SHA1 Message Date
lerko f5b46585c3 feat: add title and description fields to capture grammar
Implement | prefix for titles and // separator for descriptions
across the full stack: parser, schema, API, CLI, and web frontend.

- Parser: line-aware extraction for |title, |title // desc,
  // leading desc, body // inline desc. URL-safe (skips :// lines).
  Modifiers (#tag, @time, ^card) extracted from all segments.
- Schema: ALTER TABLE migration adds title, description columns
- DB: Entity/EntityUpdate structs, all CRUD queries updated
- API: title/description on create/update/response, body validation
  relaxed (title-only entries valid)
- CLI: shows title as scan label when present
- Web: parseInput mirrors Go parser, list shows title, detail pane
  renders title + description with double-click inline editing
- Tests: 10 new cases (grammar, entity, API) — 71 total, all pass
2026-05-15 20:52:58 -04:00
lerko e708ea5c13 Merge pull request 'feat: implement nib design system — warm amber palette, dual themes, new typography' (#4) from feat/design-system into main
Reviewed-on: #4
2026-05-14 21:04:38 +00:00
lerko aa7c9aef7d feat: implement nib design system — warm amber palette, dual themes, new typography
Replace Tokyonight/Catppuccin blue palette with warm amber/ink identity.
Dual theme support (noir + paper) via data-theme attribute with
localStorage persistence. Space Grotesk for chrome, Monaspace Neon for
content. Updated glyph set (Strokes: — ○ ◇) across web and CLI.
2026-05-14 17:02:11 -04:00
13 changed files with 981 additions and 379 deletions
+12 -6
View File
@@ -32,9 +32,11 @@ func runAdd(_ *cobra.Command, args []string) error {
defer store.Close() defer store.Close()
e := &db.Entity{ e := &db.Entity{
Body: parsed.Body, Body: parsed.Body,
Glyph: db.Glyph(parsed.Glyph), Title: parsed.Title,
Tags: parsed.Tags, Description: parsed.Description,
Glyph: db.Glyph(parsed.Glyph),
Tags: parsed.Tags,
} }
if parsed.TimeAnchor != nil { if parsed.TimeAnchor != nil {
e.TimeAnchor = parsed.TimeAnchor e.TimeAnchor = parsed.TimeAnchor
@@ -53,12 +55,16 @@ func runAdd(_ *cobra.Command, args []string) error {
var parts []string var parts []string
parts = append(parts, glyph) parts = append(parts, glyph)
parts = append(parts, " "+e.Body) if e.Title != nil {
parts = append(parts, " "+*e.Title)
} else {
parts = append(parts, " "+e.Body)
}
if e.TimeAnchor != nil { if e.TimeAnchor != nil {
parts = append(parts, " @"+*e.TimeAnchor) parts = append(parts, " @"+*e.TimeAnchor)
} }
for _, tag := range e.Tags { for _, tag := range e.Tags {
parts = append(parts, " #"+tag) parts = append(parts, " #"+tag)
} }
parts = append(parts, " ["+shortID+"]") parts = append(parts, " ["+shortID+"]")
if e.CardType != nil { if e.CardType != nil {
+109
View File
@@ -507,6 +507,115 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) {
} }
} }
func TestCreateEntity_WithTitle(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(srv, "/api/entities", map[string]any{
"body": "body text",
"title": "nginx trick",
"description": "always forget this",
"tags": []string{"ops"},
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
var e EntityResponse
json.NewDecoder(resp.Body).Decode(&e)
if e.Title == nil || *e.Title != "nginx trick" {
t.Errorf("title: %v", e.Title)
}
if e.Description == nil || *e.Description != "always forget this" {
t.Errorf("description: %v", e.Description)
}
}
func TestCreateEntity_TitleOnly(t *testing.T) {
srv, _ := testServer(t)
title := "title only"
resp := postJSON(srv, "/api/entities", map[string]any{
"title": title,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
var e EntityResponse
json.NewDecoder(resp.Body).Decode(&e)
if e.Title == nil || *e.Title != "title only" {
t.Errorf("title: %v", e.Title)
}
}
func TestUpdateEntity_Title(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "body", nil)
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
mustJSON(map[string]any{"title": "new title"})))
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var e EntityResponse
json.NewDecoder(resp.Body).Decode(&e)
if e.Title == nil || *e.Title != "new title" {
t.Errorf("title: %v", e.Title)
}
}
func TestUpdateEntity_Description(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "body", nil)
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
mustJSON(map[string]any{"description": "new desc"})))
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var e EntityResponse
json.NewDecoder(resp.Body).Decode(&e)
if e.Description == nil || *e.Description != "new desc" {
t.Errorf("description: %v", e.Description)
}
}
func TestListEntities_TitleInResponse(t *testing.T) {
srv, _ := testServer(t)
title := "list title"
postJSON(srv, "/api/entities", map[string]any{
"body": "body",
"title": title,
}).Body.Close()
resp, _ := http.Get(srv.URL + "/api/entities")
defer resp.Body.Close()
var entities []EntityResponse
json.NewDecoder(resp.Body).Decode(&entities)
if len(entities) != 1 {
t.Fatalf("expected 1, got %d", len(entities))
}
if entities[0].Title == nil || *entities[0].Title != "list title" {
t.Errorf("title: %v", entities[0].Title)
}
}
func mustJSON(v any) []byte { func mustJSON(v any) []byte {
b, _ := json.Marshal(v) b, _ := json.Marshal(v)
return b return b
+27 -19
View File
@@ -10,22 +10,26 @@ import (
) )
type CreateEntityRequest struct { type CreateEntityRequest struct {
Body string `json:"body"` Body string `json:"body"`
Glyph *string `json:"glyph"` Title *string `json:"title"`
TimeAnchor *string `json:"time_anchor"` Description *string `json:"description"`
Tags []string `json:"tags"` Glyph *string `json:"glyph"`
CardType *string `json:"card_type"` TimeAnchor *string `json:"time_anchor"`
CardData *string `json:"card_data"` Tags []string `json:"tags"`
CardType *string `json:"card_type"`
CardData *string `json:"card_data"`
} }
type UpdateEntityRequest struct { type UpdateEntityRequest struct {
Body *string `json:"body"` Body *string `json:"body"`
Glyph *string `json:"glyph"` Title *string `json:"title"`
TimeAnchor *string `json:"time_anchor"` Description *string `json:"description"`
Tags *[]string `json:"tags"` Glyph *string `json:"glyph"`
Pinned *bool `json:"pinned"` TimeAnchor *string `json:"time_anchor"`
CardType *string `json:"card_type"` Tags *[]string `json:"tags"`
CardData *string `json:"card_data"` Pinned *bool `json:"pinned"`
CardType *string `json:"card_type"`
CardData *string `json:"card_data"`
} }
type PromoteRequest struct { type PromoteRequest struct {
@@ -119,8 +123,8 @@ func createEntity(store *db.Store) http.HandlerFunc {
return return
} }
if req.Body == "" { if req.Body == "" && req.Title == nil {
writeError(w, http.StatusBadRequest, "invalid_input", "body is required") writeError(w, http.StatusBadRequest, "invalid_input", "body or title is required")
return return
} }
@@ -134,10 +138,12 @@ func createEntity(store *db.Store) http.HandlerFunc {
} }
e := &db.Entity{ e := &db.Entity{
Body: req.Body, Body: req.Body,
Glyph: glyph, Title: req.Title,
TimeAnchor: req.TimeAnchor, Description: req.Description,
Tags: req.Tags, Glyph: glyph,
TimeAnchor: req.TimeAnchor,
Tags: req.Tags,
} }
if req.CardType != nil { if req.CardType != nil {
@@ -186,6 +192,8 @@ func updateEntity(store *db.Store) http.HandlerFunc {
u := &db.EntityUpdate{} u := &db.EntityUpdate{}
u.Body = req.Body u.Body = req.Body
u.Title = req.Title
u.Description = req.Description
u.Tags = req.Tags u.Tags = req.Tags
u.Pinned = req.Pinned u.Pinned = req.Pinned
u.CardData = req.CardData u.CardData = req.CardData
+12 -8
View File
@@ -18,6 +18,8 @@ type EntityResponse struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"` ModifiedAt string `json:"modified_at"`
Body string `json:"body"` Body string `json:"body"`
Title *string `json:"title"`
Description *string `json:"description"`
Glyph string `json:"glyph"` Glyph string `json:"glyph"`
TimeAnchor *string `json:"time_anchor"` TimeAnchor *string `json:"time_anchor"`
CompletedAt *string `json:"completed_at"` CompletedAt *string `json:"completed_at"`
@@ -50,14 +52,16 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
func entityToResponse(e *db.Entity) EntityResponse { func entityToResponse(e *db.Entity) EntityResponse {
resp := EntityResponse{ resp := EntityResponse{
ID: e.ID, ID: e.ID,
CreatedAt: e.CreatedAt.Format(time.RFC3339), CreatedAt: e.CreatedAt.Format(time.RFC3339),
ModifiedAt: e.ModifiedAt.Format(time.RFC3339), ModifiedAt: e.ModifiedAt.Format(time.RFC3339),
Body: e.Body, Body: e.Body,
Glyph: string(e.Glyph), Title: e.Title,
Pinned: e.Pinned, Description: e.Description,
Tags: e.Tags, Glyph: string(e.Glyph),
UseCount: e.UseCount, Pinned: e.Pinned,
Tags: e.Tags,
UseCount: e.UseCount,
} }
if resp.Tags == nil { if resp.Tags == nil {
resp.Tags = []string{} resp.Tags = []string{}
+8 -1
View File
@@ -84,7 +84,14 @@ func (s *Store) migrate() error {
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
ON entity_tags(tag); ON entity_tags(tag);
`) `)
return err if err != nil {
return err
}
s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
return nil
} }
func DefaultPath() (string, error) { func DefaultPath() (string, error) {
+44 -21
View File
@@ -49,6 +49,8 @@ type Entity struct {
CreatedAt time.Time CreatedAt time.Time
ModifiedAt time.Time ModifiedAt time.Time
Body string Body string
Title *string
Description *string
Glyph Glyph Glyph Glyph
TimeAnchor *string TimeAnchor *string
CompletedAt *time.Time CompletedAt *time.Time
@@ -85,14 +87,16 @@ func DefaultListParams() ListParams {
} }
type EntityUpdate struct { type EntityUpdate struct {
Body *string Body *string
Glyph *Glyph Title *string
TimeAnchor *string Description *string
ClearTime bool Glyph *Glyph
Pinned *bool TimeAnchor *string
CardType *CardType ClearTime bool
CardData *string Pinned *bool
Tags *[]string CardType *CardType
CardData *string
Tags *[]string
} }
func (s *Store) Create(e *Entity) error { func (s *Store) Create(e *Entity) error {
@@ -111,13 +115,16 @@ func (s *Store) Create(e *Entity) error {
defer tx.Rollback() defer tx.Rollback()
_, err = tx.Exec(` _, err = tx.Exec(`
INSERT INTO entities (id, created_at, modified_at, body, glyph, time_anchor, INSERT INTO entities (id, created_at, modified_at, body, title, description,
completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at) glyph, time_anchor, completed_at, pinned, deleted_at,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, card_type, card_data, use_count, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID, e.ID,
e.CreatedAt.Format(time.RFC3339), e.CreatedAt.Format(time.RFC3339),
e.ModifiedAt.Format(time.RFC3339), e.ModifiedAt.Format(time.RFC3339),
e.Body, e.Body,
e.Title,
e.Description,
string(e.Glyph), string(e.Glyph),
e.TimeAnchor, e.TimeAnchor,
formatTimePtr(e.CompletedAt), formatTimePtr(e.CompletedAt),
@@ -144,14 +151,17 @@ func (s *Store) Get(id string) (*Entity, error) {
var createdAt, modifiedAt string var createdAt, modifiedAt string
var completedAt, deletedAt, lastUsedAt sql.NullString var completedAt, deletedAt, lastUsedAt sql.NullString
var timeAnchor, cardType, cardData sql.NullString var timeAnchor, cardType, cardData sql.NullString
var title, description sql.NullString
var pinned int var pinned int
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT id, created_at, modified_at, body, glyph, time_anchor, SELECT id, created_at, modified_at, body, title, description,
completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at glyph, time_anchor, completed_at, pinned, deleted_at,
card_type, card_data, use_count, last_used_at
FROM entities WHERE id = ?`, id).Scan( FROM entities WHERE id = ?`, id).Scan(
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor, &e.ID, &createdAt, &modifiedAt, &e.Body, &title, &description,
&completedAt, &pinned, &deletedAt, &cardType, &cardData, &e.UseCount, &lastUsedAt, &e.Glyph, &timeAnchor, &completedAt, &pinned, &deletedAt,
&cardType, &cardData, &e.UseCount, &lastUsedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, ErrNotFound return nil, ErrNotFound
@@ -162,6 +172,8 @@ func (s *Store) Get(id string) (*Entity, error) {
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt) e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
e.Title = nullToPtr(title)
e.Description = nullToPtr(description)
e.TimeAnchor = nullToPtr(timeAnchor) e.TimeAnchor = nullToPtr(timeAnchor)
e.CompletedAt = parseTimePtr(completedAt) e.CompletedAt = parseTimePtr(completedAt)
e.Pinned = pinned != 0 e.Pinned = pinned != 0
@@ -234,9 +246,9 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
} }
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT e.id, e.created_at, e.modified_at, e.body, e.glyph, e.time_anchor, SELECT e.id, e.created_at, e.modified_at, e.body, e.title, e.description,
e.completed_at, e.pinned, e.deleted_at, e.card_type, e.card_data, e.glyph, e.time_anchor, e.completed_at, e.pinned, e.deleted_at,
e.use_count, e.last_used_at e.card_type, e.card_data, e.use_count, e.last_used_at
FROM entities e FROM entities e
%s %s
ORDER BY %s %s ORDER BY %s %s
@@ -256,18 +268,21 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
var createdAt, modifiedAt string var createdAt, modifiedAt string
var completedAt, deletedAt, lastUsedAt sql.NullString var completedAt, deletedAt, lastUsedAt sql.NullString
var timeAnchor, cardType, cardData sql.NullString var timeAnchor, cardType, cardData sql.NullString
var title, description sql.NullString
var pinned int var pinned int
if err := rows.Scan( if err := rows.Scan(
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor, &e.ID, &createdAt, &modifiedAt, &e.Body, &title, &description,
&completedAt, &pinned, &deletedAt, &cardType, &cardData, &e.Glyph, &timeAnchor, &completedAt, &pinned, &deletedAt,
&e.UseCount, &lastUsedAt, &cardType, &cardData, &e.UseCount, &lastUsedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt) e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
e.Title = nullToPtr(title)
e.Description = nullToPtr(description)
e.TimeAnchor = nullToPtr(timeAnchor) e.TimeAnchor = nullToPtr(timeAnchor)
e.CompletedAt = parseTimePtr(completedAt) e.CompletedAt = parseTimePtr(completedAt)
e.Pinned = pinned != 0 e.Pinned = pinned != 0
@@ -308,6 +323,14 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
sets = append(sets, "body = ?") sets = append(sets, "body = ?")
args = append(args, *u.Body) args = append(args, *u.Body)
} }
if u.Title != nil {
sets = append(sets, "title = ?")
args = append(args, *u.Title)
}
if u.Description != nil {
sets = append(sets, "description = ?")
args = append(args, *u.Description)
}
if u.Glyph != nil { if u.Glyph != nil {
sets = append(sets, "glyph = ?") sets = append(sets, "glyph = ?")
args = append(args, string(*u.Glyph)) args = append(args, string(*u.Glyph))
+96
View File
@@ -441,3 +441,99 @@ func TestResolve_NotFound(t *testing.T) {
t.Errorf("expected ErrNotFound, got %v", err) t.Errorf("expected ErrNotFound, got %v", err)
} }
} }
func TestCreate_WithTitleAndDescription(t *testing.T) {
s := testStore(t)
e := &Entity{
Body: "body text",
Title: ptr("nginx trick"),
Description: ptr("always forget this"),
Glyph: GlyphNote,
Tags: []string{"ops"},
}
if err := s.Create(e); err != nil {
t.Fatal(err)
}
got, err := s.Get(e.ID)
if err != nil {
t.Fatal(err)
}
if got.Title == nil || *got.Title != "nginx trick" {
t.Errorf("title: got %v", got.Title)
}
if got.Description == nil || *got.Description != "always forget this" {
t.Errorf("description: got %v", got.Description)
}
if got.Body != "body text" {
t.Errorf("body: got %q", got.Body)
}
}
func TestCreate_WithoutTitle(t *testing.T) {
s := testStore(t)
e := &Entity{Body: "just body", Glyph: GlyphNote}
if err := s.Create(e); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
if got.Title != nil {
t.Errorf("expected nil title, got %v", got.Title)
}
if got.Description != nil {
t.Errorf("expected nil description, got %v", got.Description)
}
}
func TestUpdate_Title(t *testing.T) {
s := testStore(t)
e := &Entity{Body: "body", Glyph: GlyphNote}
s.Create(e)
newTitle := "new title"
if err := s.Update(e.ID, &EntityUpdate{Title: &newTitle}); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
if got.Title == nil || *got.Title != "new title" {
t.Errorf("title: got %v", got.Title)
}
}
func TestUpdate_Description(t *testing.T) {
s := testStore(t)
e := &Entity{Body: "body", Glyph: GlyphNote}
s.Create(e)
newDesc := "new desc"
if err := s.Update(e.ID, &EntityUpdate{Description: &newDesc}); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
if got.Description == nil || *got.Description != "new desc" {
t.Errorf("description: got %v", got.Description)
}
}
func TestAbsorb_PreservesTargetTitle(t *testing.T) {
s := testStore(t)
target := &Entity{Body: "target body", Title: ptr("target title"), Glyph: GlyphNote}
source := &Entity{Body: "source body", Title: ptr("source title"), Glyph: GlyphNote}
s.Create(target)
s.Create(source)
if err := s.Absorb(target.ID, source.ID); err != nil {
t.Fatal(err)
}
got, _ := s.Get(target.ID)
if got.Title == nil || *got.Title != "target title" {
t.Errorf("target title should be preserved, got %v", got.Title)
}
if got.Body != "target body\nsource body" {
t.Errorf("body: got %q", got.Body)
}
}
+3 -3
View File
@@ -3,8 +3,8 @@ package display
import "github.com/lerko/nib/internal/db" import "github.com/lerko/nib/internal/db"
var glyphMap = map[db.Glyph]string{ var glyphMap = map[db.Glyph]string{
db.GlyphNote: "", db.GlyphNote: "",
db.GlyphTodo: "", db.GlyphTodo: "",
db.GlyphEvent: "◇", db.GlyphEvent: "◇",
} }
@@ -25,7 +25,7 @@ func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
if g, ok := glyphMap[glyph]; ok { if g, ok := glyphMap[glyph]; ok {
return g return g
} }
return "" return ""
} }
func FormatID(id string) string { func FormatID(id string) string {
+136 -56
View File
@@ -7,11 +7,13 @@ import (
) )
type Result struct { type Result struct {
Body string Body string
Glyph string Glyph string
TimeAnchor *string Title *string
Tags []string Description *string
CardSuffix *string TimeAnchor *string
Tags []string
CardSuffix *string
} }
var validCardTypes = map[string]string{ var validCardTypes = map[string]string{
@@ -35,61 +37,139 @@ func Parse(input string) (*Result, error) {
Tags: []string{}, Tags: []string{},
} }
tokens := strings.Fields(input) remaining := input
if len(tokens) == 0 {
return nil, fmt.Errorf("empty input")
}
first := tokens[0] if sp := strings.IndexByte(remaining, ' '); sp >= 0 {
switch first { switch remaining[:sp] {
case "-", "▸": case "-", "▸":
r.Glyph = "todo" r.Glyph = "todo"
tokens = tokens[1:] remaining = strings.TrimSpace(remaining[sp+1:])
case "*", "◇": case "*", "◇":
r.Glyph = "event" r.Glyph = "event"
tokens = tokens[1:] remaining = strings.TrimSpace(remaining[sp+1:])
} }
} else {
var bodyParts []string switch remaining {
seen := map[string]bool{} case "-", "▸":
r.Glyph = "todo"
for _, tok := range tokens { remaining = ""
switch { case "*", "◇":
case strings.HasPrefix(tok, "@") && len(tok) > 1: r.Glyph = "event"
timeStr := tok[1:] remaining = ""
if err := validateTime(timeStr); err != nil {
return nil, fmt.Errorf("invalid time %q: %w", timeStr, err)
}
if r.TimeAnchor != nil {
return nil, fmt.Errorf("multiple time anchors")
}
r.TimeAnchor = &timeStr
case strings.HasPrefix(tok, "#") && len(tok) > 1:
tag := tok[1:]
if !seen[tag] {
r.Tags = append(r.Tags, tag)
seen[tag] = true
}
case strings.HasPrefix(tok, "^") && len(tok) > 1:
suffix := tok[1:]
cardType, ok := validCardTypes[suffix]
if !ok {
return nil, fmt.Errorf("invalid card type %q", suffix)
}
if r.CardSuffix != nil {
return nil, fmt.Errorf("multiple card suffixes")
}
r.CardSuffix = &cardType
default:
bodyParts = append(bodyParts, tok)
} }
} }
r.Body = strings.Join(bodyParts, " ") var titleRaw, descRaw string
if r.Body == "" { hasTitle := false
lines := strings.SplitN(remaining, "\n", 2)
firstLine := strings.TrimSpace(lines[0])
if strings.HasPrefix(firstLine, "|") {
hasTitle = true
titleContent := firstLine[1:]
if idx := strings.Index(titleContent, " // "); idx >= 0 {
titleRaw = strings.TrimSpace(titleContent[:idx])
descRaw = strings.TrimSpace(titleContent[idx+4:])
} else {
titleRaw = strings.TrimSpace(titleContent)
}
if len(lines) > 1 {
remaining = lines[1]
} else {
remaining = ""
}
} else {
allLines := strings.Split(remaining, "\n")
var descParts []string
startBody := 0
for i, line := range allLines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "// ") || trimmed == "//" {
descParts = append(descParts, strings.TrimSpace(trimmed[2:]))
startBody = i + 1
} else {
break
}
}
if len(descParts) > 0 {
descRaw = strings.Join(descParts, " ")
remaining = strings.Join(allLines[startBody:], "\n")
} else if !strings.Contains(firstLine, "://") {
if idx := strings.Index(firstLine, " // "); idx >= 0 {
descRaw = strings.TrimSpace(firstLine[idx+4:])
remaining = strings.TrimSpace(firstLine[:idx])
if len(lines) > 1 {
remaining += "\n" + lines[1]
}
}
}
}
seen := map[string]bool{}
extract := func(text string) (string, error) {
tokens := strings.Fields(text)
var parts []string
for _, tok := range tokens {
switch {
case strings.HasPrefix(tok, "@") && len(tok) > 1:
timeStr := tok[1:]
if err := validateTime(timeStr); err != nil {
return "", fmt.Errorf("invalid time %q: %w", timeStr, err)
}
if r.TimeAnchor != nil {
return "", fmt.Errorf("multiple time anchors")
}
r.TimeAnchor = &timeStr
case strings.HasPrefix(tok, "#") && len(tok) > 1:
tag := tok[1:]
if !seen[tag] {
r.Tags = append(r.Tags, tag)
seen[tag] = true
}
case strings.HasPrefix(tok, "^") && len(tok) > 1:
suffix := tok[1:]
cardType, ok := validCardTypes[suffix]
if !ok {
return "", fmt.Errorf("invalid card type %q", suffix)
}
if r.CardSuffix != nil {
return "", fmt.Errorf("multiple card suffixes")
}
r.CardSuffix = &cardType
default:
parts = append(parts, tok)
}
}
return strings.Join(parts, " "), nil
}
if hasTitle {
clean, err := extract(titleRaw)
if err != nil {
return nil, err
}
if clean != "" {
r.Title = &clean
}
}
if descRaw != "" {
clean, err := extract(descRaw)
if err != nil {
return nil, err
}
if clean != "" {
r.Description = &clean
}
}
clean, err := extract(remaining)
if err != nil {
return nil, err
}
r.Body = clean
if r.Body == "" && r.Title == nil {
return nil, fmt.Errorf("empty body after extracting modifiers") return nil, fmt.Errorf("empty body after extracting modifiers")
} }
+45 -24
View File
@@ -13,46 +13,61 @@ func TestParse(t *testing.T) {
input string input string
wantBody string wantBody string
wantGlyph string wantGlyph string
wantTitle *string
wantDesc *string
wantTime *string wantTime *string
wantTags []string wantTags []string
wantCard *string wantCard *string
wantErrSub string wantErrSub string
}{ }{
// Glyph detection // Glyph detection
{"plain note", "hello world", "hello world", "note", nil, nil, nil, ""}, {"plain note", "hello world", "hello world", "note", nil, nil, nil, nil, nil, ""},
{"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""}, {"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, ""},
{"unicode todo", "▸ deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""}, {"unicode todo", "▸ deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, ""},
{"star event", "* dentist", "dentist", "event", nil, nil, nil, ""}, {"star event", "* dentist", "dentist", "event", nil, nil, nil, nil, nil, ""},
{"unicode event", "◇ dentist", "dentist", "event", nil, nil, nil, ""}, {"unicode event", "◇ dentist", "dentist", "event", nil, nil, nil, nil, nil, ""},
// Time anchor // Time anchor
{"with time", "meeting @14:00", "meeting", "note", sp("14:00"), nil, nil, ""}, {"with time", "meeting @14:00", "meeting", "note", nil, nil, sp("14:00"), nil, nil, ""},
{"time at start", "@9:30 standup", "standup", "note", sp("9:30"), nil, nil, ""}, {"time at start", "@9:30 standup", "standup", "note", nil, nil, sp("9:30"), nil, nil, ""},
{"invalid hours", "meeting @25:00", "", "", nil, nil, nil, "invalid time"}, {"invalid hours", "meeting @25:00", "", "", nil, nil, nil, nil, nil, "invalid time"},
{"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, "invalid time"}, {"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, nil, nil, "invalid time"},
// Tags // Tags
{"single tag", "deploy #ops", "deploy", "note", nil, []string{"ops"}, nil, ""}, {"single tag", "deploy #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""},
{"multiple tags", "deploy #ops #infra", "deploy", "note", nil, []string{"ops", "infra"}, nil, ""}, {"multiple tags", "deploy #ops #infra", "deploy", "note", nil, nil, nil, []string{"ops", "infra"}, nil, ""},
{"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, []string{"ops"}, nil, ""}, {"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""},
{"tag with hyphen", "task #dev-ops", "task", "note", nil, []string{"dev-ops"}, nil, ""}, {"tag with hyphen", "task #dev-ops", "task", "note", nil, nil, nil, []string{"dev-ops"}, nil, ""},
// Card suffix // Card suffix
{"caret card", "trick #nginx ^card", "trick", "note", nil, []string{"nginx"}, sp("snippet"), ""}, {"caret card", "trick #nginx ^card", "trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), ""},
{"caret c", "trick ^c", "trick", "note", nil, nil, sp("snippet"), ""}, {"caret c", "trick ^c", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""},
{"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, sp("template"), ""}, {"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, nil, nil, sp("template"), ""},
{"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, sp("snippet"), ""}, {"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""},
{"invalid card type", "thing ^bogus", "", "", nil, nil, nil, "invalid card type"}, {"invalid card type", "thing ^bogus", "", "", nil, nil, nil, nil, nil, "invalid card type"},
// Combined // Combined
{"full input", "- deploy nginx to staging @15:00 #ops", "deploy nginx to staging", "todo", sp("15:00"), []string{"ops"}, nil, ""}, {"full input", "- deploy nginx to staging @15:00 #ops", "deploy nginx to staging", "todo", nil, nil, sp("15:00"), []string{"ops"}, nil, ""},
{"full with card", "figured out the proxy_pass trick #nginx ^card", "figured out the proxy_pass trick", "note", nil, []string{"nginx"}, sp("snippet"), ""}, {"full with card", "figured out the proxy_pass trick #nginx ^card", "figured out the proxy_pass trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), ""},
// Title
{"title with body", "|nginx trick\nproxy_pass trailing slash #ops", "proxy_pass trailing slash", "note", sp("nginx trick"), nil, nil, []string{"ops"}, nil, ""},
{"no title", "no pipe here #ops", "no pipe here", "note", nil, nil, nil, []string{"ops"}, nil, ""},
{"todo with title", "- |deploy staging\nrebuild docker #ops", "rebuild docker", "todo", sp("deploy staging"), nil, nil, []string{"ops"}, nil, ""},
{"title only", "|title only", "", "note", sp("title only"), nil, nil, nil, nil, ""},
{"title and desc", "|title // description #ops\nbody here", "body here", "note", sp("title"), sp("description"), nil, []string{"ops"}, nil, ""},
{"todo title desc", "- |deploy staging // rebuild and push #ops", "", "todo", sp("deploy staging"), sp("rebuild and push"), nil, []string{"ops"}, nil, ""},
// Description without title
{"leading desc", "// leading desc\nbody content", "body content", "note", nil, sp("leading desc"), nil, nil, nil, ""},
{"inline desc", "body text // inline desc", "body text", "note", nil, sp("inline desc"), nil, nil, nil, ""},
{"url no split", "http://example.com // should not split", "http://example.com // should not split", "note", nil, nil, nil, nil, nil, ""},
// Edge cases // Edge cases
{"empty input", "", "", "", nil, nil, nil, "empty"}, {"empty input", "", "", "", nil, nil, nil, nil, nil, "empty"},
{"only glyph", "-", "", "", nil, nil, nil, "empty body"}, {"only glyph", "-", "", "", nil, nil, nil, nil, nil, "empty body"},
{"only modifiers", "#ops @14:00", "", "", nil, nil, nil, "empty body"}, {"only modifiers", "#ops @14:00", "", "", nil, nil, nil, nil, nil, "empty body"},
{"whitespace only", " ", "", "", nil, nil, nil, "empty"}, {"whitespace only", " ", "", "", nil, nil, nil, nil, nil, "empty"},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -79,6 +94,12 @@ func TestParse(t *testing.T) {
if got.Glyph != tt.wantGlyph { if got.Glyph != tt.wantGlyph {
t.Errorf("glyph: got %q, want %q", got.Glyph, tt.wantGlyph) t.Errorf("glyph: got %q, want %q", got.Glyph, tt.wantGlyph)
} }
if !ptrEq(got.Title, tt.wantTitle) {
t.Errorf("title: got %v, want %v", strPtr(got.Title), strPtr(tt.wantTitle))
}
if !ptrEq(got.Description, tt.wantDesc) {
t.Errorf("description: got %v, want %v", strPtr(got.Description), strPtr(tt.wantDesc))
}
if !ptrEq(got.TimeAnchor, tt.wantTime) { if !ptrEq(got.TimeAnchor, tt.wantTime) {
t.Errorf("time_anchor: got %v, want %v", strPtr(got.TimeAnchor), strPtr(tt.wantTime)) t.Errorf("time_anchor: got %v, want %v", strPtr(got.TimeAnchor), strPtr(tt.wantTime))
} }
+149 -26
View File
@@ -2,7 +2,7 @@
'use strict'; 'use strict';
const GLYPHS = { const GLYPHS = {
note: '', todo: '', event: '◇', note: '', todo: '', event: '◇',
snippet: '◆', template: '◈', checklist: '☐', snippet: '◆', template: '◈', checklist: '☐',
decision: '⚖', link: '↗', decision: '⚖', link: '↗',
}; };
@@ -115,37 +115,92 @@
input = input.trim(); input = input.trim();
if (!input) return null; if (!input) return null;
const tokens = input.split(/\s+/);
let glyph = 'note'; let glyph = 'note';
let remaining = input;
const first = tokens[0]; const sp = remaining.indexOf(' ');
if (first === '-' || first === '▸') { glyph = 'todo'; tokens.shift(); } if (sp >= 0) {
else if (first === '*' || first === '◇') { glyph = 'event'; tokens.shift(); } const first = remaining.slice(0, sp);
if (first === '-' || first === '▸') { glyph = 'todo'; remaining = remaining.slice(sp + 1).trim(); }
else if (first === '*' || first === '◇') { glyph = 'event'; remaining = remaining.slice(sp + 1).trim(); }
} else {
if (remaining === '-' || remaining === '▸') { glyph = 'todo'; remaining = ''; }
else if (remaining === '*' || remaining === '◇') { glyph = 'event'; remaining = ''; }
}
const bodyParts = []; let titleRaw = null, descRaw = null, hasTitle = false;
let timeAnchor = null; const lines = remaining.split('\n');
const tags = []; const firstLine = (lines[0] || '').trim();
const seenTags = {};
let cardSuffix = null;
for (const tok of tokens) { if (firstLine.startsWith('|')) {
if (tok.startsWith('@') && tok.length > 1) { hasTitle = true;
timeAnchor = tok.slice(1); const titleContent = firstLine.slice(1);
} else if (tok.startsWith('#') && tok.length > 1) { const descIdx = titleContent.indexOf(' // ');
const tag = tok.slice(1); if (descIdx >= 0) {
if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; } titleRaw = titleContent.slice(0, descIdx).trim();
} else if (tok.startsWith('^') && tok.length > 1) { descRaw = titleContent.slice(descIdx + 4).trim();
const suffix = tok.slice(1);
if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix];
} else { } else {
bodyParts.push(tok); titleRaw = titleContent.trim();
}
remaining = lines.slice(1).join('\n');
} else {
let descParts = [], startBody = 0;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed.startsWith('// ') || trimmed === '//') {
descParts.push(trimmed.slice(2).trim());
startBody = i + 1;
} else { break; }
}
if (descParts.length) {
descRaw = descParts.join(' ');
remaining = lines.slice(startBody).join('\n');
} else if (!firstLine.includes('://')) {
const dIdx = firstLine.indexOf(' // ');
if (dIdx >= 0) {
descRaw = firstLine.slice(dIdx + 4).trim();
remaining = firstLine.slice(0, dIdx).trim();
if (lines.length > 1) remaining += '\n' + lines.slice(1).join('\n');
}
} }
} }
const body = bodyParts.join(' '); let timeAnchor = null, cardSuffix = null;
if (!body) return null; const tags = [], seenTags = {};
return { body, glyph, timeAnchor, tags, cardSuffix }; function extract(text) {
const tokens = text.split(/\s+/).filter(Boolean);
const parts = [];
for (const tok of tokens) {
if (tok.startsWith('@') && tok.length > 1) {
timeAnchor = tok.slice(1);
} else if (tok.startsWith('#') && tok.length > 1) {
const tag = tok.slice(1);
if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; }
} else if (tok.startsWith('^') && tok.length > 1) {
const suffix = tok.slice(1);
if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix];
} else {
parts.push(tok);
}
}
return parts.join(' ');
}
let title = null, description = null;
if (hasTitle) {
const clean = extract(titleRaw || '');
if (clean) title = clean;
}
if (descRaw) {
const clean = extract(descRaw);
if (clean) description = clean;
}
const body = extract(remaining);
if (!body && !title) return null;
return { body, glyph, title, description, timeAnchor, tags, cardSuffix };
} }
function detectCardType(body) { function detectCardType(body) {
@@ -222,7 +277,7 @@
const groups = groupByDate(state.entities); const groups = groupByDate(state.entities);
let idx = 0; let idx = 0;
for (const g of groups) { for (const g of groups) {
html += `<div class="date-header">── ${g.label} ──</div>`; html += `<div class="date-header">${g.label}</div>`;
for (const e of g.entities) { for (const e of g.entities) {
html += renderEntityItem(e, idx); html += renderEntityItem(e, idx);
idx++; idx++;
@@ -258,9 +313,17 @@
const time = e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''; const time = e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : '';
const useBadge = e.use_count > 0 ? `<span class="use-badge">${e.use_count}×</span>` : ''; const useBadge = e.use_count > 0 ? `<span class="use-badge">${e.use_count}×</span>` : '';
let label;
if (e.title) {
const preview = e.body ? `<span class="entity-preview">${escHtml(e.body)}</span>` : '';
label = `<span class="entity-title">${escHtml(e.title)}</span>${preview}`;
} else {
label = `<span class="entity-body">${escHtml(e.body)}</span>`;
}
return `<div class="entity-item ${selected}" data-index="${idx}" data-id="${e.id}"> return `<div class="entity-item ${selected}" data-index="${idx}" data-id="${e.id}">
<span class="entity-glyph ${gc}">${glyph}</span> <span class="entity-glyph ${gc}">${glyph}</span>
<span class="entity-body">${escHtml(e.body)}</span> ${label}
${time} ${time}
<span class="entity-tags">${tags}</span> <span class="entity-tags">${tags}</span>
<span class="entity-meta">${useBadge}</span> <span class="entity-meta">${useBadge}</span>
@@ -296,18 +359,27 @@
} }
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`; actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
const descHtml = e.description ? `<div class="detail-desc" data-id="${e.id}">${escHtml(e.description)}</div>` : '';
const titleHtml = e.title ? `<h2 class="detail-title" data-id="${e.id}">${escHtml(e.title)}</h2>` : '';
pane.innerHTML = ` pane.innerHTML = `
<div class="detail-header"> <div class="detail-header">
<span class="detail-glyph ${gc}">${glyph}</span> <span class="detail-glyph ${gc}">${glyph}</span>
<span class="detail-id">${shortId}</span> <span class="detail-id">${shortId}</span>
${e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''} ${e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''}
</div> </div>
${descHtml}
${titleHtml}
<div class="detail-body" data-id="${e.id}">${escHtml(e.body)}</div> <div class="detail-body" data-id="${e.id}">${escHtml(e.body)}</div>
${tags ? `<div class="detail-tags">${tags}</div>` : ''} ${tags ? `<div class="detail-tags">${tags}</div>` : ''}
${cardContent} ${cardContent}
<div class="detail-actions">${actions}</div> <div class="detail-actions">${actions}</div>
`; `;
const titleEl = pane.querySelector('.detail-title');
if (titleEl) titleEl.addEventListener('dblclick', () => startEditField('title'));
const descEl = pane.querySelector('.detail-desc');
if (descEl) descEl.addEventListener('dblclick', () => startEditField('description'));
const bodyEl = pane.querySelector('.detail-body'); const bodyEl = pane.querySelector('.detail-body');
if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody); if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody);
} }
@@ -395,6 +467,40 @@
}); });
} }
function startEditField(field) {
const e = state.entities[state.selectedIndex];
if (!e) return;
const cls = field === 'title' ? '.detail-title' : '.detail-desc';
const el = $(`${cls}[data-id="${e.id}"]`);
if (!el || el.tagName === 'INPUT') return;
const input = document.createElement('input');
input.type = 'text';
input.className = 'detail-field-edit';
input.value = e[field] || '';
input.placeholder = field;
el.replaceWith(input);
input.focus();
async function save() {
const val = input.value.trim();
if (val !== (e[field] || '')) {
await api.updateEntity(e.id, { [field]: val || null });
await loadEntities();
const idx = state.entities.findIndex(x => x.id === e.id);
if (idx >= 0) selectEntity(idx);
} else {
renderDetailPane();
}
}
input.addEventListener('blur', save);
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') { ev.preventDefault(); input.removeEventListener('blur', save); save(); }
if (ev.key === 'Escape') { ev.preventDefault(); input.removeEventListener('blur', save); renderDetailPane(); }
});
}
// ========== Actions ========== // ========== Actions ==========
function selectEntity(idx) { function selectEntity(idx) {
@@ -565,9 +671,10 @@
list.innerHTML = sources.map(e => { list.innerHTML = sources.map(e => {
const g = displayGlyph(e); const g = displayGlyph(e);
const gc = glyphClass(e); const gc = glyphClass(e);
const label = e.title ? escHtml(e.title) : escHtml(e.body);
return `<div class="absorb-source-item" data-id="${e.id}"> return `<div class="absorb-source-item" data-id="${e.id}">
<span class="entity-glyph ${gc}">${g}</span> <span class="entity-glyph ${gc}">${g}</span>
<span class="entity-body">${escHtml(e.body)}</span> <span class="entity-body">${label}</span>
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -615,6 +722,8 @@
glyph: parsed.glyph, glyph: parsed.glyph,
tags: parsed.tags, tags: parsed.tags,
}; };
if (parsed.title) data.title = parsed.title;
if (parsed.description) data.description = parsed.description;
if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor; if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor;
if (parsed.cardSuffix) data.card_type = parsed.cardSuffix; if (parsed.cardSuffix) data.card_type = parsed.cardSuffix;
@@ -754,6 +863,20 @@
return escHtml(s).replace(/'/g, '&#39;'); return escHtml(s).replace(/'/g, '&#39;');
} }
// ========== Theme ==========
const themeToggle = $('#theme-toggle');
let nibTheme = localStorage.getItem('nib:theme') || 'dark';
document.documentElement.setAttribute('data-theme', nibTheme);
themeToggle.textContent = nibTheme === 'paper' ? '◐' : '◑';
themeToggle.addEventListener('click', () => {
nibTheme = nibTheme === 'dark' ? 'paper' : 'dark';
document.documentElement.setAttribute('data-theme', nibTheme);
localStorage.setItem('nib:theme', nibTheme);
themeToggle.textContent = nibTheme === 'paper' ? '◐' : '◑';
});
// ========== Init ========== // ========== Init ==========
async function init() { async function init() {
+12 -2
View File
@@ -1,10 +1,19 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>nib</title> <title>nib</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
<style>
@font-face { font-family: 'Monaspace Neon'; font-weight: 300; src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Light.woff2') format('woff2'); }
@font-face { font-family: 'Monaspace Neon'; font-weight: 400; src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Monaspace Neon'; font-weight: 500; src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Medium.woff2') format('woff2'); }
@font-face { font-family: 'Monaspace Neon'; font-weight: 700; src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Bold.woff2') format('woff2'); }
</style>
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -17,8 +26,9 @@
</nav> </nav>
</div> </div>
<form id="capture-bar" autocomplete="off"> <form id="capture-bar" autocomplete="off">
<input type="text" id="capture-input" placeholder="capture... (n to focus)" spellcheck="false"> <input type="text" id="capture-input" placeholder="capture — - todo # note * event" spellcheck="false">
</form> </form>
<button class="theme-toggle" id="theme-toggle" title="toggle theme"></button>
</header> </header>
<main> <main>
<aside id="tag-rail"></aside> <aside id="tag-rail"></aside>
+328 -213
View File
@@ -1,29 +1,63 @@
/* ── TOKENS ─────────────────────────────────────────── */
:root { :root {
--bg: #1a1b26; color-scheme: dark;
--bg-surface: #24283b; --bg: #0c0b09;
--bg-hover: #292e42; --surf: #111009;
--bg-selected: #33394d; --raised: #1a1715;
--text: #c0caf5; --border: #252118;
--text-dim: #565f89; --soft: #1e1b16;
--text-muted: #3b4261; --text: #e8dfc8;
--accent: #7aa2f7; --muted: #8c8070;
--accent-dim: #3d59a1; --dim: #504840;
--green: #9ece6a; --accent: #c8942a;
--red: #f7768e; --a-bg: rgba(200,148,42,.09);
--yellow: #e0af68; --todo: #d4a84b;
--orange: #ff9e64; --note: #6ab8b0;
--purple: #bb9af7; --event: #6898c8;
--cyan: #7dcfff; --remind: #c8784a;
--border: #292e42; --ok: #7aab72;
--radius: 6px; --danger: #b85858;
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace; --lineage: #9878bc;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; --pin: #c8942a;
--sans: 'Space Grotesk', system-ui, sans-serif;
--mono: 'Monaspace Neon', ui-monospace, monospace;
--r1: 2px;
--r2: 4px;
--r3: 8px;
--t-fast: 80ms ease;
--t-base: 200ms ease;
} }
* { margin: 0; padding: 0; box-sizing: border-box; } [data-theme="paper"] {
color-scheme: light;
--bg: #f4efe4;
--surf: #faf7f0;
--raised: #ece7db;
--border: #d4cdc0;
--soft: #e6e0d4;
--text: #1c1810;
--muted: #6a5e50;
--dim: #a09080;
--accent: #8a6018;
--a-bg: rgba(138,96,24,.08);
--todo: #7a5c00;
--note: #1a7070;
--event: #245890;
--remind: #984020;
--ok: #2a6828;
--danger: #882030;
--lineage: #5830a0;
--pin: #8a6018;
}
/* ── RESET ──────────────────────────────────────────── */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
::-webkit-scrollbar { width: 3px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
body { body {
font-family: var(--font-sans); font-family: var(--sans);
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-size: 14px; font-size: 14px;
@@ -38,14 +72,15 @@ body {
height: 100vh; height: 100vh;
} }
/* Header */ /* ── HEADER ─────────────────────────────────────────── */
header { header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
padding: 12px 20px; padding: 0 20px;
height: 36px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg-surface); background: var(--surf);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -57,32 +92,33 @@ header {
} }
.logo { .logo {
font-family: var(--font-mono); font-family: var(--mono);
font-size: 18px; font-size: 15px;
font-weight: 700; font-weight: 300;
color: var(--accent); color: var(--accent);
letter-spacing: -0.5px; letter-spacing: .3em;
} }
nav { nav {
display: flex; display: flex;
gap: 4px; gap: 2px;
} }
.nav-btn { .nav-btn {
background: none; background: none;
border: 1px solid transparent; border: none;
color: var(--text-dim); color: var(--dim);
padding: 4px 12px; padding: 4px 8px;
border-radius: var(--radius); border-radius: var(--r1);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 11px;
font-family: var(--font-mono); font-family: var(--sans);
transition: all 0.15s; font-weight: 500;
transition: color var(--t-fast), background var(--t-fast);
} }
.nav-btn:hover { color: var(--text); background: var(--bg-hover); } .nav-btn:hover { color: var(--muted); }
.nav-btn.active { color: var(--accent); border-color: var(--accent-dim); background: var(--bg); } .nav-btn.active { color: var(--accent); background: var(--a-bg); }
#capture-bar { #capture-bar {
flex: 1; flex: 1;
@@ -92,31 +128,36 @@ nav {
#capture-input { #capture-input {
width: 100%; width: 100%;
background: var(--bg); background: var(--bg);
border: 1px solid var(--text-muted); border: 1px solid var(--border);
color: var(--text); color: var(--text);
padding: 8px 12px; padding: 4px 10px;
border-radius: var(--radius); border-radius: var(--r2);
font-family: var(--font-mono); font-family: var(--mono);
font-size: 13px; font-size: 12px;
outline: none; outline: none;
transition: border-color 0.15s, box-shadow 0.15s; transition: border-color var(--t-fast);
} }
#capture-input:hover { #capture-input:hover { border-color: var(--muted); }
border-color: var(--accent-dim); #capture-input:focus { border-color: var(--accent); }
box-shadow: 0 0 0 1px var(--accent-dim); #capture-input::placeholder { color: var(--dim); }
.theme-toggle {
background: none;
border: 1px solid var(--border);
border-radius: var(--r1);
color: var(--dim);
font-family: var(--mono);
font-size: 13px;
padding: 2px 8px;
cursor: pointer;
flex-shrink: 0;
transition: color var(--t-fast), border-color var(--t-fast);
} }
#capture-input:focus { .theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
#capture-input::placeholder { /* ── MAIN LAYOUT ────────────────────────────────────── */
color: var(--text-dim);
}
/* Main layout */
main { main {
display: grid; display: grid;
grid-template-columns: 180px 1fr 320px; grid-template-columns: 180px 1fr 320px;
@@ -124,36 +165,38 @@ main {
overflow: hidden; overflow: hidden;
} }
/* Tag rail */ /* ── TAG RAIL ───────────────────────────────────────── */
#tag-rail { #tag-rail {
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
padding: 12px 0; padding: 12px 0;
overflow-y: auto; overflow-y: auto;
background: var(--surf);
} }
.tag-item { .tag-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 6px 16px; padding: 4px 16px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 11px;
color: var(--text-dim); color: var(--muted);
transition: all 0.1s; transition: color var(--t-fast), background var(--t-fast);
} }
.tag-item:hover { background: var(--bg-hover); color: var(--text); } .tag-item:hover { background: var(--raised); color: var(--text); }
.tag-item.active { color: var(--accent); background: var(--bg-selected); } .tag-item.active { color: var(--accent); background: var(--a-bg); }
.tag-name { font-family: var(--font-mono); } .tag-name { font-family: var(--mono); font-size: 11px; }
.tag-name::before { content: '#'; color: var(--text-muted); } .tag-name::before { content: '#'; color: var(--dim); }
.tag-count { .tag-count {
font-size: 11px; font-family: var(--mono);
color: var(--text-muted); font-size: 10px;
color: var(--dim);
min-width: 20px; min-width: 20px;
text-align: right; text-align: right;
} }
/* Entity panel */ /* ── ENTITY PANEL ───────────────────────────────────── */
#entity-panel { #entity-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -164,32 +207,30 @@ main {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 20px; padding: 6px 20px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--soft);
flex-shrink: 0; flex-shrink: 0;
} }
#month-nav:empty { #month-nav:empty { display: none; }
display: none;
}
.month-nav-btn { .month-nav-btn {
background: none; background: none;
border: none; border: none;
color: var(--text-dim); color: var(--dim);
font-family: var(--font-mono); font-family: var(--mono);
font-size: 13px; font-size: 11px;
cursor: pointer; cursor: pointer;
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: var(--r1);
transition: all 0.1s; transition: color var(--t-fast), background var(--t-fast);
} }
.month-nav-btn:hover { color: var(--text); background: var(--bg-hover); } .month-nav-btn:hover { color: var(--text); background: var(--raised); }
.month-nav-label { .month-nav-label {
font-family: var(--font-mono); font-family: var(--mono);
font-size: 13px; font-size: 11px;
color: var(--text); color: var(--text);
min-width: 80px; min-width: 80px;
text-align: center; text-align: center;
@@ -198,75 +239,108 @@ main {
.month-nav-clear { .month-nav-clear {
background: none; background: none;
border: none; border: none;
color: var(--text-muted); color: var(--dim);
font-family: var(--font-mono); font-family: var(--mono);
font-size: 11px; font-size: 10px;
cursor: pointer; cursor: pointer;
margin-left: auto; margin-left: auto;
transition: color var(--t-fast);
} }
.month-nav-clear:hover { color: var(--text); } .month-nav-clear:hover { color: var(--text); }
/* Entity list */ /* ── ENTITY LIST ────────────────────────────────────── */
#entity-list { #entity-list {
overflow-y: auto; overflow-y: auto;
padding: 8px 0; padding: 4px 0;
flex: 1; flex: 1;
} }
.date-header { .date-header {
display: flex;
align-items: center;
gap: .6rem;
padding: 8px 20px 4px; padding: 8px 20px 4px;
font-size: 11px; font-size: 10px;
font-family: var(--font-mono); font-family: var(--mono);
color: var(--text-muted); color: var(--dim);
text-transform: lowercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: .2em;
}
.date-header::after {
content: '';
flex: 1;
height: 1px;
background: var(--soft);
} }
.entity-item { .entity-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
padding: 8px 20px; padding: 6px 20px;
cursor: pointer; cursor: pointer;
transition: background 0.1s; transition: background var(--t-fast);
border-left: 2px solid transparent; border-left: 2px solid transparent;
} }
.entity-item:hover { background: var(--bg-hover); } .entity-item:hover { background: var(--raised); }
.entity-item.selected { .entity-item.selected {
background: var(--bg-selected); background: var(--surf);
border-left-color: var(--accent); border-left-color: var(--accent);
} }
.entity-glyph { .entity-glyph {
font-size: 14px; font-family: var(--mono);
width: 20px; font-size: 12px;
width: 14px;
text-align: center; text-align: center;
flex-shrink: 0; flex-shrink: 0;
font-weight: 500;
} }
.glyph-note { color: var(--text-dim); } .glyph-note { color: var(--dim); }
.glyph-todo { color: var(--green); } .glyph-todo { color: var(--todo); }
.glyph-event { color: var(--yellow); } .glyph-event { color: var(--event); }
.glyph-snippet { color: var(--accent); } .glyph-snippet { color: var(--accent); }
.glyph-template { color: var(--purple); } .glyph-template { color: var(--lineage); }
.glyph-checklist { color: var(--orange); } .glyph-checklist { color: var(--remind); }
.glyph-decision { color: var(--cyan); } .glyph-decision { color: var(--note); }
.glyph-link { color: var(--red); } .glyph-link { color: var(--danger); }
.entity-title {
font-family: var(--sans);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entity-preview {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-left: 8px;
}
.entity-body { .entity-body {
flex: 1; flex: 1;
font-size: 13px; font-family: var(--mono);
font-size: 12px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.entity-time { .entity-time {
font-family: var(--font-mono); font-family: var(--mono);
font-size: 11px; font-size: 10px;
color: var(--text-dim); color: var(--dim);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -277,40 +351,42 @@ main {
} }
.entity-tag { .entity-tag {
font-family: var(--font-mono); font-family: var(--mono);
font-size: 10px; font-size: 9px;
color: var(--accent-dim); color: var(--muted);
background: rgba(122, 162, 247, 0.1); border: 1px solid var(--border);
padding: 1px 6px; padding: 1px 6px;
border-radius: 3px; border-radius: var(--r1);
} }
.entity-meta { .entity-meta {
font-family: var(--font-mono); font-family: var(--mono);
font-size: 11px; font-size: 10px;
color: var(--text-muted); color: var(--dim);
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.use-badge { .use-badge {
color: var(--yellow); color: var(--todo);
font-size: 10px; font-size: 10px;
} }
/* Detail pane */ /* ── DETAIL PANE ────────────────────────────────────── */
#detail-pane { #detail-pane {
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
padding: 20px; padding: 20px;
overflow-y: auto; overflow-y: auto;
background: var(--surf);
} }
.detail-empty { .detail-empty {
color: var(--text-muted); color: var(--dim);
font-size: 13px; font-size: 12px;
text-align: center; text-align: center;
margin-top: 40px; margin-top: 40px;
font-family: var(--mono);
} }
.detail-header { .detail-header {
@@ -320,44 +396,85 @@ main {
margin-bottom: 16px; margin-bottom: 16px;
} }
.detail-glyph { font-size: 20px; } .detail-glyph { font-size: 16px; }
.detail-id { .detail-id {
font-family: var(--font-mono); font-family: var(--mono);
font-size: 10px;
color: var(--dim);
}
.detail-desc {
font-family: var(--sans);
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--muted);
margin-bottom: 4px;
cursor: text;
padding: 2px 6px;
margin-left: -6px;
border-radius: var(--r2);
transition: background var(--t-fast);
}
.detail-desc:hover { background: var(--raised); }
.detail-title {
font-family: var(--sans);
font-size: 16px;
font-weight: 500;
margin-bottom: 12px;
cursor: text;
padding: 2px 6px;
margin-left: -6px;
border-radius: var(--r2);
transition: background var(--t-fast);
}
.detail-title:hover { background: var(--raised); }
.detail-field-edit {
display: block;
width: 100%;
font-family: var(--sans);
font-size: 14px;
margin-bottom: 12px;
padding: 4px 8px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--accent);
border-radius: var(--r2);
outline: none;
} }
.detail-body { .detail-body {
font-size: 14px; font-family: var(--mono);
font-size: 13px;
line-height: 1.7; line-height: 1.7;
margin-bottom: 16px; margin-bottom: 16px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
cursor: text; cursor: text;
border-radius: var(--radius); border-radius: var(--r2);
padding: 4px 6px; padding: 4px 6px;
margin-left: -6px; margin-left: -6px;
transition: background 0.1s; transition: background var(--t-fast);
} }
.detail-body:hover { .detail-body:hover { background: var(--raised); }
background: var(--bg-hover);
}
.detail-body-edit { .detail-body-edit {
display: block; display: block;
width: 100%; width: 100%;
min-height: 80px; min-height: 80px;
font-family: var(--font-sans); font-family: var(--mono);
font-size: 14px; font-size: 13px;
line-height: 1.7; line-height: 1.7;
margin-bottom: 16px; margin-bottom: 16px;
padding: 6px 8px; padding: 6px 8px;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
border: 1px solid var(--accent); border: 1px solid var(--accent);
border-radius: var(--radius); border-radius: var(--r2);
outline: none; outline: none;
resize: vertical; resize: vertical;
white-space: pre-wrap; white-space: pre-wrap;
@@ -372,38 +489,43 @@ main {
} }
.detail-tag { .detail-tag {
font-family: var(--font-mono); font-family: var(--mono);
font-size: 12px; font-size: 11px;
color: var(--accent); color: var(--accent);
background: rgba(122, 162, 247, 0.1); border: 1px solid currentColor;
border-color: color-mix(in srgb, var(--accent) 38%, transparent);
background: var(--a-bg);
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: var(--r1);
} }
.detail-actions { .detail-actions {
display: flex; display: flex;
gap: 8px; gap: 6px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.action-btn { .action-btn {
background: var(--bg-hover); background: none;
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text); color: var(--muted);
padding: 6px 14px; padding: 4px 12px;
border-radius: var(--radius); border-radius: var(--r1);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 11px;
font-family: var(--font-mono); font-family: var(--mono);
transition: all 0.15s; display: inline-flex;
align-items: center;
gap: 4px;
transition: color var(--t-fast), border-color var(--t-fast);
} }
.action-btn:hover { border-color: var(--accent); color: var(--accent); } .action-btn:hover { border-color: var(--accent); color: var(--accent); }
.action-btn.primary { background: var(--accent-dim); border-color: var(--accent); color: white; } .action-btn.primary { border-color: var(--accent); color: var(--accent); background: var(--a-bg); }
.action-btn.danger { border-color: var(--red); color: var(--red); } .action-btn.danger { color: var(--danger); border-color: var(--danger); }
.action-btn.danger:hover { background: rgba(247, 118, 142, 0.1); } .action-btn.danger:hover { background: color-mix(in srgb, var(--danger) 8%, transparent); }
/* Template slot form */ /* ── TEMPLATE SLOTS ─────────────────────────────────── */
.slot-form { margin: 16px 0; } .slot-form { margin: 16px 0; }
.slot-field { .slot-field {
@@ -414,9 +536,9 @@ main {
} }
.slot-label { .slot-label {
font-family: var(--font-mono); font-family: var(--mono);
font-size: 12px; font-size: 11px;
color: var(--purple); color: var(--lineage);
min-width: 80px; min-width: 80px;
} }
@@ -426,15 +548,16 @@ main {
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text); color: var(--text);
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: var(--r2);
font-family: var(--font-mono); font-family: var(--mono);
font-size: 12px; font-size: 11px;
outline: none; outline: none;
transition: border-color var(--t-fast);
} }
.slot-input:focus { border-color: var(--purple); } .slot-input:focus { border-color: var(--lineage); }
/* Checklist */ /* ── CHECKLIST ──────────────────────────────────────── */
.checklist-step { .checklist-step {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -442,25 +565,18 @@ main {
padding: 4px 0; padding: 4px 0;
} }
.checklist-step input[type="checkbox"] { .checklist-step input[type="checkbox"] { accent-color: var(--ok); }
accent-color: var(--green); .checklist-step.done span { text-decoration: line-through; color: var(--muted); }
}
.checklist-step.done span { /* ── DECISION CARD ──────────────────────────────────── */
text-decoration: line-through; .decision-field { margin-bottom: 12px; }
color: var(--text-dim);
}
/* Decision card */
.decision-field {
margin-bottom: 12px;
}
.decision-label { .decision-label {
font-family: var(--font-mono); font-family: var(--mono);
font-size: 11px; font-size: 10px;
color: var(--cyan); color: var(--note);
margin-bottom: 4px; text-transform: uppercase;
letter-spacing: .1em;
} }
.decision-value { .decision-value {
@@ -468,7 +584,7 @@ main {
color: var(--text); color: var(--text);
} }
/* Modal */ /* ── MODAL ──────────────────────────────────────────── */
.modal { display: none; } .modal { display: none; }
.modal.visible { display: flex; } .modal.visible { display: flex; }
@@ -484,17 +600,17 @@ main {
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
background: var(--bg-surface); background: var(--surf);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: var(--r3);
padding: 24px; padding: 24px;
z-index: 101; z-index: 101;
min-width: 320px; min-width: 320px;
} }
.modal-content h3 { .modal-content h3 {
font-family: var(--font-mono); font-family: var(--mono);
font-size: 14px; font-size: 13px;
color: var(--text); color: var(--text);
margin-bottom: 16px; margin-bottom: 16px;
font-weight: 500; font-weight: 500;
@@ -503,27 +619,28 @@ main {
.type-picker { .type-picker {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
} }
.type-btn { .type-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 16px; padding: 8px 14px;
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text); color: var(--text);
border-radius: var(--radius); border-radius: var(--r2);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 12px;
transition: all 0.15s; font-family: var(--mono);
transition: border-color var(--t-fast), background var(--t-fast);
} }
.type-btn:hover { border-color: var(--accent); background: var(--bg-hover); } .type-btn:hover { border-color: var(--accent); background: var(--raised); }
.type-btn.suggested { border-color: var(--accent-dim); background: rgba(122, 162, 247, 0.05); } .type-btn.suggested { border-color: var(--accent); background: var(--a-bg); }
.type-glyph { font-size: 16px; width: 24px; text-align: center; } .type-glyph { font-size: 14px; width: 20px; text-align: center; }
.modal-close { .modal-close {
display: block; display: block;
@@ -532,36 +649,36 @@ main {
padding: 6px; padding: 6px;
background: none; background: none;
border: none; border: none;
color: var(--text-muted); color: var(--dim);
font-size: 11px; font-size: 10px;
cursor: pointer; cursor: pointer;
font-family: var(--font-mono); font-family: var(--mono);
transition: color var(--t-fast);
} }
/* Load more */ .modal-close:hover { color: var(--muted); }
/* ── LOAD MORE ──────────────────────────────────────── */
.load-more-wrap { .load-more-wrap {
padding: 12px 20px; padding: 12px 20px;
text-align: center; text-align: center;
} }
.load-more-btn { .load-more-btn {
background: var(--bg-hover); background: none;
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text-dim); color: var(--dim);
padding: 6px 24px; padding: 4px 20px;
border-radius: var(--radius); border-radius: var(--r1);
cursor: pointer; cursor: pointer;
font-family: var(--font-mono); font-family: var(--mono);
font-size: 12px; font-size: 11px;
transition: all 0.15s; transition: color var(--t-fast), border-color var(--t-fast);
} }
.load-more-btn:hover { .load-more-btn:hover { border-color: var(--accent); color: var(--accent); }
border-color: var(--accent);
color: var(--accent);
}
/* Absorb modal */ /* ── ABSORB MODAL ───────────────────────────────────── */
.absorb-list { .absorb-list {
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
@@ -570,25 +687,23 @@ main {
.absorb-source-item { .absorb-source-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
padding: 8px 12px; padding: 6px 12px;
cursor: pointer; cursor: pointer;
border-radius: var(--radius); border-radius: var(--r2);
transition: background 0.1s; transition: background var(--t-fast);
} }
.absorb-source-item:hover { .absorb-source-item:hover { background: var(--raised); }
background: var(--bg-hover);
}
.absorb-source-item .entity-body { .absorb-source-item .entity-body {
font-size: 13px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Responsive */ /* ── RESPONSIVE ─────────────────────────────────────── */
@media (max-width: 900px) { @media (max-width: 900px) {
main { grid-template-columns: 1fr; } main { grid-template-columns: 1fr; }
#tag-rail { display: none; } #tag-rail { display: none; }
@@ -597,11 +712,11 @@ main {
inset: 0; inset: 0;
top: auto; top: auto;
height: 50vh; height: 50vh;
background: var(--bg-surface); background: var(--surf);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
border-left: none; border-left: none;
transform: translateY(100%); transform: translateY(100%);
transition: transform 0.2s; transition: transform var(--t-base);
z-index: 50; z-index: 50;
} }
#detail-pane.visible { transform: translateY(0); } #detail-pane.visible { transform: translateY(0); }