Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,36 @@ Earlier releases (v0.1.x) are documented in the

## [Unreleased]

### Changed — the generation skill now consumes the house style

The `/tinkerdown` generation skill reads a project's house style and authors
against it, closing a declared-but-unconsumed gap: `generation.style_guide` (a
markdown file of house tone/layout/do-don't) was a config field nothing ever
read. The skill now reads it — plus the `styling` block (`theme` +
`styling.tokens`) — and carries the on-brand rule into generation: **write
semantic HTML, never hardcode colours** and let the design tokens skin it, follow
the project's `style-guide.md`. The token vocabulary is documented in the skill
`reference.md` (guarded against `KnownStyleTokens` so a new token can't go
untaught). The whole style block stays optional — with none, generation falls
back to on-brand defaults. The PII reference app now ships a `style-guide.md` +
`styling.tokens` demonstrating a governed, on-brand console.

### Added — declarative design tokens (`styling.tokens`)

A project can now set its on-brand palette declaratively, without writing raw
CSS. `styling.tokens` in `tinkerdown.yaml` maps snake_case design-token names to
values — e.g. `accent: "#5a67d8"`, `card_bg: "#ffffff"` — and each drives the
matching CSS custom property (`--accent`, `--card-bg`, …) in every page's
`:root`, overriding the built-in default via the same mechanism `primary_color`
already uses for `--accent`. Because the override skins the *semantic* HTML the
generator emits, a generated UI is on-brand by construction rather than by
careful prompting. An unknown token key fails loudly at config load (naming the
key and listing the known tokens) so a typo can't silently skin nothing; token
values are sanitized against CSS-injection at render. Absent, built-in defaults
apply — existing projects are unaffected. A token override is a single value that
applies to **both** light and dark themes (same as `primary_color`); per-theme
palettes are not yet expressible.

### Added — runtime approved-surface enforcement

When a project declares a `generation:` block, Tinkerdown now enforces the
Expand Down
96 changes: 87 additions & 9 deletions docs/plans/2026-07-09-ephemeral-ui-reframe.md

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions examples/pii-access-approval/style-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# House style — PII access-approval console

This is a compliance tool. An approver makes an irreversible, audited decision
about who reaches sensitive data. The UI should read like a control panel, not a
marketing page: sober, high-contrast, and unambiguous about state.

## Tone

- Plain and factual. Label things by what they are (`Requester`, `Row cap`,
`Justification`), never with cute copy.
- Every privileged action states its consequence before it fires
(`data-confirm="Approve this request and run the bounded PII export?"`).

## Layout

- One decision per row. The pending queue is a table; each row carries the full
context an approver needs to decide without leaving the page (requester, dataset,
scope/row-cap, justification, ticket, TTL, sensitivity tags).
- Group the intake form and its queue inside a single `lvt-source` container.
- Lead with the pending queue; show approved/denied history below or behind a
toggle, not competing for attention.

## Colour and tokens — do not hardcode

- **Never write a raw colour** (`#3949ab`, `rgb(...)`, `red`) in the markup. The
house palette is set once in `styling.tokens` and applied through the page's
design tokens; hardcoding a colour bypasses it and drifts off-brand.
- Use **semantic HTML** — `<table>`, `<article>`, `<mark>`, `<strong>`, headings —
and let the tokens skin it. Semantic elements inherit `--accent`, `--card-bg`,
`--text-heading` etc. automatically.
- Convey status (pending / approved / denied) with text and semantic emphasis
(`<mark>`, `<strong>`), not with a hand-picked colour.

## Components

- Prefer a plain semantic `<table>` for the queue over any custom widget.
- Confirm-gate every state-changing button with `data-confirm`.
- No decorative imagery, gradients, or animation — this is a record of decisions.
14 changes: 14 additions & 0 deletions examples/pii-access-approval/tinkerdown.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,20 @@ actions:
generation:
sources: [access_requests, audit_log, datasets]
actions: [request-access, approve-export, deny-request]
# House style the generator authors against (Phase 2). The style guide is prose
# (tone, layout, do/don't); the design tokens below skin the semantic HTML
# on-brand by construction, so a generated console conforms without a careful
# prompt. Both are optional — absent, generation falls back to PicoCSS defaults.
style_guide: style-guide.md

styling:
theme: clean
# Sober compliance-console palette. Keys are the snake_case design tokens
# (see skills/tinkerdown/reference.md § House style); each drives the matching
# CSS custom property in every page's :root.
tokens:
accent: "#3949ab" # indigo — links and primary actions
text_heading: "#1a237e" # deep indigo for headings
card_bg: "#ffffff"
card_border: "#e0e3eb"
border_color: "#d5d9e0"
60 changes: 60 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,63 @@ type StylingConfig struct {
// shell too, while CustomCSS stays quarantined to landing pages. On landing
// pages SiteCSS loads before CustomCSS so the landing layer can override it.
SiteCSS string `yaml:"site_css,omitempty"`
// Tokens overrides individual design tokens — the on-brand palette — declaratively,
// so a team sets its brand without writing raw CSS. Keys are the snake_case names
// in KnownStyleTokens (e.g. "accent", "bg_primary", "card_bg"); each drives the
// matching CSS custom property (--accent, --bg-primary, …) in the page's :root,
// overriding the built-in default via the same mechanism primary_color uses for
// --accent. Overriding a token skins the generated (semantic) HTML on-brand by
// construction. An unknown key is a config error (ValidateStyleTokens). Absent,
// the built-in defaults apply.
Tokens map[string]string `yaml:"tokens,omitempty"`
}

// KnownStyleTokens maps each overridable design-token key (snake_case, as used in
// styling.tokens) to the CSS custom property it drives in the page's :root. This
// is the page-shell on-brand palette (server.go's :root); client-chrome tokens
// (search/tabs/toc) are internal and deliberately not author-facing.
var KnownStyleTokens = map[string]string{
"bg_primary": "--bg-primary",
"bg_secondary": "--bg-secondary",
"text_primary": "--text-primary",
"text_secondary": "--text-secondary",
"text_heading": "--text-heading",
"border_color": "--border-color",
"accent": "--accent",
"card_bg": "--card-bg",
"card_border": "--card-border",
"card_shadow": "--card-shadow",
"code_bg": "--code-bg",
"code_border": "--code-border",
"pre_bg": "--pre-bg",
"pre_text": "--pre-text",
}

// ValidateStyleTokens rejects any styling.tokens key that is not a known design
// token, so a typo fails loudly at load rather than silently skinning nothing.
func (c *Config) ValidateStyleTokens() error {
if c == nil {
return nil
}
// Collect unknown keys and sort before reporting: iterating the map and
// returning on the first miss would name a different key run-to-run when more
// than one is wrong. Sorting makes the error message deterministic.
var unknown []string
for key := range c.Styling.Tokens {
if _, ok := KnownStyleTokens[key]; !ok {
unknown = append(unknown, key)
}
}
if len(unknown) == 0 {
return nil
}
slices.Sort(unknown)
known := make([]string, 0, len(KnownStyleTokens))
for k := range KnownStyleTokens {
known = append(known, k)
}
slices.Sort(known)
return fmt.Errorf("styling.tokens: %q is not a known design token (known: %s)", unknown[0], strings.Join(known, ", "))
}

// BlocksConfig holds block-related configuration
Expand Down Expand Up @@ -950,6 +1007,9 @@ func Load(configPath string) (*Config, error) {
if err := config.ValidateActions(); err != nil {
return nil, err
}
if err := config.ValidateStyleTokens(); err != nil {
return nil, err
}

return config, nil
}
Expand Down
132 changes: 132 additions & 0 deletions internal/config/style_tokens_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package config

import (
"os"
"path/filepath"
"strings"
"testing"
)

// styling.tokens round-trips from tinkerdown.yaml into the loaded config, and
// each known snake_case key is accepted.
func TestLoad_StylingTokens(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tinkerdown.yaml")
yaml := "title: Site\n" +
"type: site\n" +
"styling:\n" +
" tokens:\n" +
" accent: \"#5a67d8\"\n" +
" card_bg: \"#ffffff\"\n"
if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if got := cfg.Styling.Tokens["accent"]; got != "#5a67d8" {
t.Errorf("tokens[accent] = %q, want %q", got, "#5a67d8")
}
if got := cfg.Styling.Tokens["card_bg"]; got != "#ffffff" {
t.Errorf("tokens[card_bg] = %q, want %q", got, "#ffffff")
}
}

// An unknown token key is an author mistake — Load fails loudly, naming the
// offending key and listing the known tokens, rather than silently ignoring it.
func TestLoad_StylingTokensUnknownKeyRejected(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tinkerdown.yaml")
yaml := "title: Site\n" +
"type: site\n" +
"styling:\n" +
" tokens:\n" +
" accnet: \"#5a67d8\"\n" // typo'd "accent"
if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil {
t.Fatal(err)
}
_, err := Load(path)
if err == nil {
t.Fatal("Load should reject an unknown token key")
}
if !strings.Contains(err.Error(), "accnet") {
t.Errorf("error should name the offending key, got: %v", err)
}
if !strings.Contains(err.Error(), "accent") {
t.Errorf("error should list known tokens (incl. accent), got: %v", err)
}
}

// A known token key with an unsafe value is NOT a config error — ValidateStyleTokens
// gates keys, not values. The value round-trips into config and is dropped later at
// render (sanitizeCSSValue), exactly as primary_color/font handle an unsafe value.
// This locks the key-vs-value split so it can't silently change: Load succeeds, the
// bad value survives in config, and the render layer (not load) is what omits it.
func TestLoad_StylingTokensUnsafeValueAccepted(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tinkerdown.yaml")
yaml := "title: Site\n" +
"type: site\n" +
"styling:\n" +
" tokens:\n" +
" accent: \"red; }\"\n" // known key, unsafe value (CSS meta-characters)
if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load should accept a known key regardless of value (values are gated at render): %v", err)
}
if got := cfg.Styling.Tokens["accent"]; got != "red; }" {
t.Errorf("value should round-trip into config unchanged (render sanitizes, not load): got %q", got)
}
}

// Absent styling.tokens is backward-compatible: an empty map, no error.
func TestLoad_StylingTokensDefaultsEmpty(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tinkerdown.yaml")
if err := os.WriteFile(path, []byte("title: Site\ntype: site\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Styling.Tokens) != 0 {
t.Errorf("cfg.Styling.Tokens = %v, want empty", cfg.Styling.Tokens)
}
}

// With more than one unknown key, ValidateStyleTokens names the sorted-first key so
// the error is deterministic run-to-run (not whichever the map iteration yields).
func TestValidateStyleTokens_MultipleUnknownDeterministic(t *testing.T) {
c := &Config{Styling: StylingConfig{Tokens: map[string]string{
"zzz_bogus": "x",
"aaa_bogus": "x",
}}}
err := c.ValidateStyleTokens()
if err == nil {
t.Fatal("expected an error for unknown token keys")
}
if !strings.Contains(err.Error(), "aaa_bogus") {
t.Errorf("error should name the sorted-first unknown key (aaa_bogus): %v", err)
}
if strings.Contains(err.Error(), "zzz_bogus") {
t.Errorf("error should name only the deterministic first key, not zzz_bogus: %v", err)
}
}

// ValidateStyleTokens accepts every key in KnownStyleTokens — guards against the
// map and the validator drifting apart.
func TestValidateStyleTokens_AllKnownKeysAccepted(t *testing.T) {
tokens := make(map[string]string, len(KnownStyleTokens))
for k := range KnownStyleTokens {
tokens[k] = "#000000"
}
c := &Config{Styling: StylingConfig{Tokens: tokens}}
if err := c.ValidateStyleTokens(); err != nil {
t.Errorf("all known tokens should validate, got: %v", err)
}
}
35 changes: 32 additions & 3 deletions internal/server/styling.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package server
import (
"fmt"
"html"
"slices"
"strings"

"github.com/livetemplate/tinkerdown/internal/config"
Expand All @@ -27,20 +28,48 @@ func themeDefault(s config.StylingConfig) string {
return "auto"
}

// buildStylingOverrideCSS returns a <style> block overriding theme tokens
// based on user config. Returns "" if no overrides are set.
// buildStylingOverrideCSS returns a <style> block overriding theme tokens based
// on user config (primary_color, font, and the styling.tokens design-token
// palette). Returns "" if no overrides are set.
func buildStylingOverrideCSS(s config.StylingConfig) string {
primary := sanitizeCSSValue(s.PrimaryColor)
font := sanitizeCSSValue(s.Font)
if primary == "" && font == "" {

// Design-token overrides, in deterministic key order. Unknown keys are
// rejected at config load (ValidateStyleTokens); skip defensively here.
keys := make([]string, 0, len(s.Tokens))
for k := range s.Tokens {
keys = append(keys, k)
}
slices.Sort(keys)
var tokenDecls []string
for _, key := range keys {
cssVar, ok := config.KnownStyleTokens[key]
if !ok {
continue
}
if val := sanitizeCSSValue(s.Tokens[key]); val != "" {
tokenDecls = append(tokenDecls, fmt.Sprintf("%s: %s;", cssVar, val))
}
}

if primary == "" && font == "" && len(tokenDecls) == 0 {
return ""
}

var out strings.Builder
out.WriteString("<style>\n")
// primary_color is shorthand for --accent; emit first so an explicit
// tokens.accent wins if a project sets both.
if primary != "" {
fmt.Fprintf(&out, " :root { --accent: %s; }\n", primary)
}
// The token block is injected after the built-in :root and [data-theme="dark"]
// blocks (server.go), so — same specificity, later source order — it wins for
// both themes, exactly as --accent does.
if len(tokenDecls) > 0 {
fmt.Fprintf(&out, " :root { %s }\n", strings.Join(tokenDecls, " "))
}
if font != "" {
fmt.Fprintf(&out, " body { font-family: %s, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif; }\n", font)
}
Expand Down
23 changes: 23 additions & 0 deletions internal/server/styling_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,29 @@ func TestBuildStylingOverrideCSS_Both(t *testing.T) {
}
}

func TestBuildStylingOverrideCSS_Tokens(t *testing.T) {
got := buildStylingOverrideCSS(config.StylingConfig{
Tokens: map[string]string{"accent": "#ff00ff", "card_bg": "#00ff00"},
})
// snake_case keys map to the CSS custom property, emitted into :root.
if !strings.Contains(got, "--accent: #ff00ff;") {
t.Errorf("missing --accent override: %q", got)
}
if !strings.Contains(got, "--card-bg: #00ff00;") {
t.Errorf("missing --card-bg override: %q", got)
}
}

func TestBuildStylingOverrideCSS_TokensSanitized(t *testing.T) {
// A token value that tries to break out of the CSS context is dropped, not emitted.
got := buildStylingOverrideCSS(config.StylingConfig{
Tokens: map[string]string{"accent": "red; } body { display:none"},
})
if strings.Contains(got, "display:none") {
t.Errorf("malicious token value should be sanitized away, got: %q", got)
}
}

func TestSanitizeCSSValue_BlocksInjection(t *testing.T) {
blocked := []string{
"</style><script>alert(1)</script>",
Expand Down
Loading
Loading