diff --git a/docs/STYLE.md b/docs/STYLE.md new file mode 100644 index 0000000..cc17466 --- /dev/null +++ b/docs/STYLE.md @@ -0,0 +1,515 @@ +# Silo Style Guide + +> Living reference for the Silo web UI. All modules must follow these conventions to maintain visual consistency across the platform. + +--- + +## Color System + +Silo uses the [Catppuccin Mocha](https://github.com/catppuccin/catppuccin) palette exclusively. All colors are referenced via CSS custom properties defined at `:root`. + +### Palette + +``` +--ctp-rosewater: #f5e0dc +--ctp-flamingo: #f2cdcd +--ctp-pink: #f5c2e7 +--ctp-mauve: #cba6f7 +--ctp-red: #f38ba8 +--ctp-maroon: #eba0ac +--ctp-peach: #fab387 +--ctp-yellow: #f9e2af +--ctp-green: #a6e3a1 +--ctp-teal: #94e2d5 +--ctp-sky: #89dceb +--ctp-sapphire: #74c7ec +--ctp-blue: #89b4fa +--ctp-lavender: #b4befe +--ctp-text: #cdd6f4 +--ctp-subtext1: #bac2de +--ctp-subtext0: #a6adc8 +--ctp-overlay2: #9399b2 +--ctp-overlay1: #7f849c +--ctp-overlay0: #6c7086 +--ctp-surface2: #585b70 +--ctp-surface1: #45475a +--ctp-surface0: #313244 +--ctp-base: #1e1e2e +--ctp-mantle: #181825 +--ctp-crust: #11111b +``` + +### Semantic Roles + +| Role | Token | Usage | +|------|-------|-------| +| Page background | `--ctp-base` | Main content area | +| Panel background | `--ctp-mantle` | Sidebars, detail panes, headers | +| Inset/input background | `--ctp-crust` | Form inputs, code blocks, drop zones | +| Primary accent | `--ctp-mauve` | Primary buttons, active states, links, selection highlights | +| Secondary accent | `--ctp-blue` | Informational highlights, secondary actions | +| Success | `--ctp-green` | Confirmations, positive status | +| Warning | `--ctp-yellow` | Caution states, pending actions | +| Danger | `--ctp-red` | Destructive actions, errors, required indicators | +| Informational | `--ctp-teal` | Auto-generated metadata, system-assigned values | +| Body text | `--ctp-text` | Primary content | +| Secondary text | `--ctp-subtext1` | Descriptions, timestamps | +| Muted text | `--ctp-overlay1` | Placeholders, disabled states | +| Borders | `--ctp-surface0` | Dividers, panel edges | +| Hover borders | `--ctp-surface1` | Interactive element borders, row separators | +| Focus ring | `rgba(203, 166, 247, 0.25)` | `box-shadow` on focused inputs (mauve at 25%) | + +### Accent Usage for Data Types + +| Data type | Color | Token | +|-----------|-------|-------| +| Assembly | `--ctp-mauve` | Badge, icon tint | +| Part | `--ctp-green` | Badge, icon tint | +| Document | `--ctp-blue` | Badge, icon tint | +| Purchased | `--ctp-peach` | Badge, icon tint | +| Phantom | `--ctp-overlay1` | Badge, icon tint | + +These mappings are used anywhere item types appear: list badges, detail pane headers, BOM entries, tree views. + +--- + +## Typography + +### Scale + +| Role | Size | Weight | Token/Color | Transform | +|------|------|--------|-------------|-----------| +| Page title | 1.1rem | 600 | `--ctp-text` | None | +| Section header | 11px | 600 | `--ctp-overlay0` | Uppercase, `letter-spacing: 0.06em` | +| Form label | 11px | 600 | `--ctp-overlay1` | Uppercase, `letter-spacing: 0.05em` | +| Body text | 13px | 400 | `--ctp-text` | None | +| Table cell | 12px | 400 | `--ctp-text` | None | +| Caption / metadata | 11px | 400 | `--ctp-subtext0` | None | +| Badge text | 10px | 600 | Varies | Uppercase | +| Breadcrumb segment | 13px | 500 | `--ctp-subtext1` | None | +| Breadcrumb active | 13px | 600 | `--ctp-text` | None | + +### Font Stack + +```css +font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; +``` + +No external font dependencies. System fonts ensure fast rendering and native feel across platforms. + +### Rules + +- Never use font sizes below 10px. +- Use `font-weight: 600` for emphasis instead of bold (700). Reserve 700 for page titles only when extra weight is needed. +- `text-transform: uppercase` is reserved for section headers, form labels, and badges. Never uppercase body text or descriptions. + +--- + +## Spacing + +Base unit: **4px**. All spacing values are multiples of 4. + +| Token | Value | Usage | +|-------|-------|-------| +| `xs` | 4px (0.25rem) | Tight gaps: icon-to-label, tag internal padding | +| `sm` | 8px (0.5rem) | Compact spacing: between related fields, badge padding | +| `md` | 12px (0.75rem) | Standard: form group gaps, sidebar section padding | +| `lg` | 16px (1rem) | Section separation, card padding | +| `xl` | 24px (1.5rem) | Page-level padding, major section breaks | +| `2xl` | 32px (2rem) | Page horizontal padding | + +### Application + +- **Page padding:** `1.5rem 2rem` (24px vertical, 32px horizontal) +- **Sidebar section padding:** `1rem 1.25rem` +- **Form grid gap:** `1.25rem 1.5rem` (row gap × column gap) +- **Table row height:** 36px minimum (padding included) +- **Table cell padding:** `0.4rem 0.75rem` + +--- + +## Layout + +### Page Structure + +Every module page follows the same shell: + +``` +┌─────────────────────────────────────────────────┐ +│ Top Nav (52px) │ +├──────────┬──────────────────────────────────────┤ +│ App Menu │ Page Header (58px) │ +│ (icons) ├──────────────────────┬───────────────┤ +│ │ Content Area │ Detail Pane │ +│ │ │ (360px) │ +│ │ │ │ +│ │ │ │ +└──────────┴──────────────────────┴───────────────┘ +``` + +- **Top nav:** `52px` height, `--ctp-mantle` background, `1px solid --ctp-surface0` bottom border. +- **App menu sidebar:** Icon strip on the left. Module icons, tooltips on hover. Active module highlighted with `--ctp-mauve` indicator. +- **Page header:** `58px` height, `--ctp-mantle` background. Contains page title (with module icon), action buttons right-aligned. +- **Content area:** `--ctp-base` background. Scrollable. Contains list views, kanban boards, or other primary content. +- **Detail pane:** `360px` fixed width, `--ctp-mantle` background, `1px solid --ctp-surface0` left border. Appears on record selection. + +### Grid Patterns + +**Two-column form:** +```css +display: grid; +grid-template-columns: 1fr 1fr; +gap: 1.25rem 1.5rem; +max-width: 800px; +``` + +**List + detail:** +```css +display: grid; +grid-template-columns: 1fr 360px; +min-height: calc(100vh - 52px - 58px); +``` + +### Breakpoints + +Not currently required. Silo targets desktop browsers on engineering workstations. If mobile support is added later, breakpoints will be defined at `768px` and `1024px`. + +--- + +## Components + +### Buttons + +Four tiers. All buttons share a base style: + +```css +display: inline-flex; +align-items: center; +gap: 0.35rem; +padding: 0.4rem 0.85rem; +border-radius: 6px; +font-size: 12px; +font-weight: 500; +cursor: pointer; +transition: all 0.15s; +``` + +| Tier | Name | Background | Border | Text | Hover | +|------|------|-----------|--------|------|-------| +| Primary | `.btn-primary` | `--ctp-mauve` | `--ctp-mauve` | `--ctp-crust` | `--ctp-lavender` bg + border | +| Secondary | `.btn` (default) | `--ctp-surface0` | `--ctp-surface1` | `--ctp-text` | `--ctp-surface1` bg, `--ctp-overlay0` border | +| Ghost | `.btn-ghost` | transparent | transparent | `--ctp-subtext0` | `--ctp-surface0` bg, `--ctp-text` text | +| Danger | `.btn-danger` | transparent | `--ctp-surface1` | `--ctp-red` | `rgba(243, 139, 168, 0.1)` bg, `--ctp-red` border | + +Primary is used once per visible context (the main action). All other actions use secondary or ghost. Danger is only for destructive actions and always requires confirmation. + +### Badges + +Used for type indicators, status labels, and tags. + +```css +display: inline-flex; +align-items: center; +padding: 0.15rem 0.5rem; +border-radius: 4px; +font-size: 10px; +font-weight: 600; +text-transform: uppercase; +letter-spacing: 0.03em; +``` + +Badges use a translucent background derived from their accent color: + +```css +/* Example: assembly badge */ +background: rgba(203, 166, 247, 0.15); /* --ctp-mauve at 15% */ +color: var(--ctp-mauve); +``` + +Standard badge colors follow the [accent usage table](#accent-usage-for-data-types). Status badges: + +| Status | Color | +|--------|-------| +| Active / Released | `--ctp-green` | +| Draft / In Progress | `--ctp-blue` | +| Review / Pending | `--ctp-yellow` | +| Obsolete / Rejected | `--ctp-red` | +| Locked | `--ctp-overlay1` | + +### Form Inputs + +All inputs share a base style: + +```css +background: var(--ctp-crust); +border: 1px solid var(--ctp-surface1); +border-radius: 6px; +padding: 0.45rem 0.65rem; +font-size: 12px; +color: var(--ctp-text); +transition: border-color 0.15s; +``` + +| State | Border | Shadow | +|-------|--------|--------| +| Default | `--ctp-surface1` | None | +| Hover | `--ctp-overlay0` | None | +| Focus | `--ctp-mauve` | `0 0 0 0.2rem rgba(203, 166, 247, 0.25)` | +| Error | `--ctp-red` | `0 0 0 0.2rem rgba(243, 139, 168, 0.15)` | +| Disabled | `--ctp-surface0` | None, `opacity: 0.5` | + +Placeholder text: `--ctp-overlay0`. Labels sit above inputs (never inline or floating). + +### Tag Input + +Used for multi-value fields (projects, tags): + +```css +display: flex; +flex-wrap: wrap; +gap: 0.3rem; +padding: 0.35rem 0.5rem; +background: var(--ctp-crust); +border: 1px solid var(--ctp-surface1); +border-radius: 6px; +min-height: 36px; +``` + +Individual tags use the badge pattern: `rgba(accent, 0.15)` background with accent text. Remove button (×) at `opacity: 0.6`, `1.0` on hover. + +### Tables + +```css +width: 100%; +border-collapse: collapse; +font-size: 12px; +``` + +| Element | Style | +|---------|-------| +| Header row | `background: --ctp-mantle`, `font-size: 11px`, uppercase, `--ctp-overlay1` text | +| Body row | `border-bottom: 1px solid --ctp-surface0` | +| Row hover | `background: --ctp-surface0` | +| Row selected | `background: rgba(203, 166, 247, 0.08)` | +| Cell padding | `0.4rem 0.75rem` | +| Text columns | Left-aligned | +| Number columns | Right-aligned | +| Date columns | Right-aligned | +| Action columns | Center-aligned | + +Row actions use icon buttons (not text links). Icons at 14px, `--ctp-overlay1` default, `--ctp-text` on hover. + +### Tabs + +Used in detail panes and module sub-views: + +```css +display: flex; +gap: 0; +border-bottom: 2px solid var(--ctp-surface0); +``` + +| State | Style | +|-------|-------| +| Default | `padding: 0.5rem 1rem`, `--ctp-subtext0` text, no border | +| Hover | `--ctp-text` text | +| Active | `--ctp-text` text, `font-weight: 600`, `border-bottom: 2px solid --ctp-mauve` (overlaps container border) | + +### Section Dividers + +Used to visually group form fields: + +```css +display: flex; +align-items: center; +gap: 0.75rem; +grid-column: 1 / -1; /* span full form grid */ +margin-top: 0.75rem; +``` + +Contains a label (`11px`, uppercase, `--ctp-overlay0`) and a horizontal line (`flex: 1`, `1px solid --ctp-surface0`). + +### Sidebar Sections + +Stacked vertically within detail panes: + +```css +padding: 1rem 1.25rem; +border-bottom: 1px solid var(--ctp-surface0); +``` + +Last section has no bottom border. Section titles follow the section header typography (11px, uppercase, `--ctp-overlay0`). + +### Tooltips + +Appear on hover after a 300ms delay. Position: above the target element by default, flip below if insufficient space. + +```css +background: var(--ctp-surface0); +border: 1px solid var(--ctp-surface1); +border-radius: 4px; +padding: 0.3rem 0.6rem; +font-size: 11px; +color: var(--ctp-text); +box-shadow: 0 4px 12px rgba(17, 17, 27, 0.4); +``` + +### Breadcrumbs + +Module navigation breadcrumbs: + +``` +Module Name > List View > Record Name > Sub-view +``` + +Separator: `>` character in `--ctp-overlay0`. Segments are clickable links in `--ctp-subtext1`. Active (final) segment is `--ctp-text` at `font-weight: 600`. + +### Dropdowns / Selects + +Follow the input base style. The dropdown menu: + +```css +background: var(--ctp-surface0); +border: 1px solid var(--ctp-surface1); +border-radius: 6px; +box-shadow: 0 8px 24px rgba(17, 17, 27, 0.5); +padding: 0.25rem; +max-height: 240px; +overflow-y: auto; +``` + +Menu items: + +```css +padding: 0.4rem 0.65rem; +border-radius: 4px; +font-size: 12px; +color: var(--ctp-text); +cursor: pointer; +``` + +Hover: `background: --ctp-surface1`. Selected: `background: rgba(203, 166, 247, 0.12)`, `color: --ctp-mauve`, `font-weight: 600`. + +--- + +## Icons + +Use [Lucide](https://lucide.dev) icons. Size: 14px for inline/table contexts, 16px for buttons and navigation, 20px for page headers and empty states. + +Stroke width: 1.5px (Lucide default). Color inherits from parent text color unless explicitly set. + +Do not mix icon libraries. If Lucide does not have a suitable icon, request one be added or create a custom SVG following Lucide's 24×24 grid and stroke conventions. + +--- + +## Transitions & Animation + +All interactive state changes use `transition: all 0.15s ease`. This applies to hover, focus, active, and open/close states. + +No entrance animations on page load. Content renders immediately. Skeleton loaders are acceptable for async data using a pulsing `--ctp-surface0` → `--ctp-surface1` gradient. + +Dropdown menus and tooltips appear instantly (no slide/fade). Collapse/expand panels (if used) transition `max-height` at `0.2s ease`. + +--- + +## Styling Implementation + +Silo's React frontend uses **inline `React.CSSProperties` objects** with `var(--ctp-*)` token references. This is the project convention and must not be changed. + +### Rules + +- No CSS modules, no Tailwind, no external CSS-in-JS libraries. +- Styles are defined as `const` objects at the top of each component file. +- Shared style patterns (button base, input base) can be extracted to a `styles/` directory as exported `CSSProperties` objects. +- Use `as const` or `as React.CSSProperties` for type safety. +- Pseudo-classes (`:hover`, `:focus`) require state-driven inline styles or a thin CSS file for the base pseudo-class rules. + +### Example + +```typescript +const styles = { + container: { + display: 'grid', + gridTemplateColumns: '1fr 360px', + height: '100%', + overflow: 'hidden', + } as React.CSSProperties, + + sidebar: { + background: 'var(--ctp-mantle)', + borderLeft: '1px solid var(--ctp-surface0)', + display: 'flex', + flexDirection: 'column' as const, + overflowY: 'auto' as const, + } as React.CSSProperties, +}; +``` + +### Pseudo-class CSS + +A single `silo-base.css` file provides pseudo-class rules that cannot be expressed inline: + +```css +/* Hover, focus, and active states for core interactive elements */ +.silo-input:hover { border-color: var(--ctp-overlay0); } +.silo-input:focus { border-color: var(--ctp-mauve); box-shadow: 0 0 0 0.2rem rgba(203, 166, 247, 0.25); } +.silo-btn:hover { /* per-tier overrides */ } +.silo-row:hover { background: var(--ctp-surface0); } +``` + +Components apply the corresponding class names alongside their inline styles. This is the only place class-based styling is used. + +--- + +## Do / Don't + +| Do | Don't | +|----|-------| +| Use `var(--ctp-*)` for every color | Hardcode hex values | +| Use the 4px spacing scale | Use arbitrary padding/margins | +| Use Lucide icons at standard sizes | Mix icon libraries | +| Use inline `CSSProperties` | Use CSS modules or Tailwind | +| One primary button per visible context | Multiple competing primary buttons | +| Use translucent accent backgrounds for badges | Use solid bright backgrounds for badges | +| Use icon buttons for row-level table actions | Use text links in table rows | +| Define styles as `const` at file top | Inline style objects in JSX | +| Show tooltips on icon-only buttons | Leave icon buttons unlabeled | +| Use section dividers to group form fields | Use cards or borders around field groups | +| Follow the breadcrumb pattern for navigation | Use nested tab bars | + +--- + +## Appendix: CSS Custom Properties Block + +Paste this at the root of the application stylesheet: + +```css +:root { + --ctp-rosewater: #f5e0dc; + --ctp-flamingo: #f2cdcd; + --ctp-pink: #f5c2e7; + --ctp-mauve: #cba6f7; + --ctp-red: #f38ba8; + --ctp-maroon: #eba0ac; + --ctp-peach: #fab387; + --ctp-yellow: #f9e2af; + --ctp-green: #a6e3a1; + --ctp-teal: #94e2d5; + --ctp-sky: #89dceb; + --ctp-sapphire: #74c7ec; + --ctp-blue: #89b4fa; + --ctp-lavender: #b4befe; + --ctp-text: #cdd6f4; + --ctp-subtext1: #bac2de; + --ctp-subtext0: #a6adc8; + --ctp-overlay2: #9399b2; + --ctp-overlay1: #7f849c; + --ctp-overlay0: #6c7086; + --ctp-surface2: #585b70; + --ctp-surface1: #45475a; + --ctp-surface0: #313244; + --ctp-base: #1e1e2e; + --ctp-mantle: #181825; + --ctp-crust: #11111b; +} +``` diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 605cb89..0f2ac90 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -621,6 +621,12 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { return } + if err := s.partgen.Validate(partNumber, schemaName); err != nil { + s.logger.Error().Err(err).Str("part_number", partNumber).Msg("generated part number failed validation") + writeError(w, http.StatusInternalServerError, "validation_failed", err.Error()) + return + } + item = &db.Item{ PartNumber: partNumber, ItemType: itemType, diff --git a/internal/partnum/generator.go b/internal/partnum/generator.go index 31b3776..6325c3f 100644 --- a/internal/partnum/generator.go +++ b/internal/partnum/generator.go @@ -178,7 +178,84 @@ func (g *Generator) Validate(partNumber string, schemaName string) error { return fmt.Errorf("unknown schema: %s", schemaName) } - // TODO: parse part number and validate each segment - _ = s + parts := strings.Split(partNumber, s.Separator) + if len(parts) != len(s.Segments) { + return fmt.Errorf("expected %d segments, got %d", len(s.Segments), len(parts)) + } + + for i, seg := range s.Segments { + val := parts[i] + if err := g.validateSegment(&seg, val); err != nil { + return fmt.Errorf("segment %s: %w", seg.Name, err) + } + } + + return nil +} + +// validateSegment checks that a single segment value is valid. +func (g *Generator) validateSegment(seg *schema.Segment, val string) error { + switch seg.Type { + case "constant": + if val != seg.Value { + return fmt.Errorf("expected %q, got %q", seg.Value, val) + } + + case "enum": + if _, ok := seg.Values[val]; !ok { + return fmt.Errorf("invalid enum value: %s", val) + } + + case "string": + if seg.Length > 0 && len(val) != seg.Length { + return fmt.Errorf("value must be exactly %d characters", seg.Length) + } + if seg.MinLength > 0 && len(val) < seg.MinLength { + return fmt.Errorf("value must be at least %d characters", seg.MinLength) + } + if seg.MaxLength > 0 && len(val) > seg.MaxLength { + return fmt.Errorf("value must be at most %d characters", seg.MaxLength) + } + if seg.Case == "upper" && val != strings.ToUpper(val) { + return fmt.Errorf("value must be uppercase") + } + if seg.Case == "lower" && val != strings.ToLower(val) { + return fmt.Errorf("value must be lowercase") + } + if seg.Validation.Pattern != "" { + re := regexp.MustCompile(seg.Validation.Pattern) + if !re.MatchString(val) { + msg := seg.Validation.Message + if msg == "" { + msg = fmt.Sprintf("value does not match pattern %s", seg.Validation.Pattern) + } + return fmt.Errorf("%s", msg) + } + } + + case "serial": + if seg.Length > 0 && len(val) != seg.Length { + return fmt.Errorf("value must be exactly %d characters", seg.Length) + } + for _, ch := range val { + if ch < '0' || ch > '9' { + return fmt.Errorf("serial must be numeric") + } + } + + case "date": + layout := seg.Value + if layout == "" { + layout = "20060102" + } + expected := time.Now().UTC().Format(layout) + if len(val) != len(expected) { + return fmt.Errorf("date segment length mismatch: expected %d, got %d", len(expected), len(val)) + } + + default: + return fmt.Errorf("unknown segment type: %s", seg.Type) + } + return nil } diff --git a/internal/partnum/generator_test.go b/internal/partnum/generator_test.go index 0e63ccb..ec420b8 100644 --- a/internal/partnum/generator_test.go +++ b/internal/partnum/generator_test.go @@ -226,6 +226,118 @@ func TestGenerateDateSegmentCustomFormat(t *testing.T) { } } +// --- Validation tests --- + +func TestValidateBasic(t *testing.T) { + s := testSchema() + gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{}) + + if err := gen.Validate("F01-0001", "test"); err != nil { + t.Fatalf("expected valid, got error: %v", err) + } +} + +func TestValidateWrongSegmentCount(t *testing.T) { + s := testSchema() + gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{}) + + if err := gen.Validate("F01-0001-EXTRA", "test"); err == nil { + t.Fatal("expected error for wrong segment count") + } +} + +func TestValidateInvalidEnum(t *testing.T) { + s := testSchema() + gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{}) + + if err := gen.Validate("ZZZ-0001", "test"); err == nil { + t.Fatal("expected error for invalid enum value") + } +} + +func TestValidateNonNumericSerial(t *testing.T) { + s := testSchema() + gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{}) + + if err := gen.Validate("F01-ABCD", "test"); err == nil { + t.Fatal("expected error for non-numeric serial") + } +} + +func TestValidateSerialWrongLength(t *testing.T) { + s := testSchema() + gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{}) + + if err := gen.Validate("F01-01", "test"); err == nil { + t.Fatal("expected error for wrong serial length") + } +} + +func TestValidateConstantSegment(t *testing.T) { + s := &schema.Schema{ + Name: "const-val", + Version: 1, + Separator: "-", + Segments: []schema.Segment{ + {Name: "prefix", Type: "constant", Value: "KS"}, + {Name: "serial", Type: "serial", Length: 4}, + }, + } + gen := NewGenerator(map[string]*schema.Schema{"const-val": s}, &mockSeqStore{}) + + if err := gen.Validate("KS-0001", "const-val"); err != nil { + t.Fatalf("expected valid, got error: %v", err) + } + if err := gen.Validate("XX-0001", "const-val"); err == nil { + t.Fatal("expected error for wrong constant value") + } +} + +func TestValidateUnknownSchema(t *testing.T) { + gen := NewGenerator(map[string]*schema.Schema{}, &mockSeqStore{}) + + if err := gen.Validate("F01-0001", "nonexistent"); err == nil { + t.Fatal("expected error for unknown schema") + } +} + +func TestValidateDateSegment(t *testing.T) { + s := &schema.Schema{ + Name: "date-val", + Version: 1, + Separator: "-", + Segments: []schema.Segment{ + {Name: "date", Type: "date"}, + {Name: "serial", Type: "serial", Length: 3}, + }, + } + gen := NewGenerator(map[string]*schema.Schema{"date-val": s}, &mockSeqStore{}) + + today := time.Now().UTC().Format("20060102") + if err := gen.Validate(today+"-001", "date-val"); err != nil { + t.Fatalf("expected valid, got error: %v", err) + } + if err := gen.Validate("20-001", "date-val"); err == nil { + t.Fatal("expected error for wrong date length") + } +} + +func TestValidateGeneratedOutput(t *testing.T) { + s := testSchema() + gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{}) + + pn, err := gen.Generate(context.Background(), Input{ + SchemaName: "test", + Values: map[string]string{"category": "F01"}, + }) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + if err := gen.Validate(pn, "test"); err != nil { + t.Fatalf("generated part number %q failed validation: %v", pn, err) + } +} + func TestGenerateDateSegmentYearOnly(t *testing.T) { s := &schema.Schema{ Name: "date-year", diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 41b21fd..92d291b 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -73,7 +73,7 @@ export function AppShell() { padding: "var(--d-nav-py) var(--d-nav-px)", borderRadius: "var(--d-nav-radius)", textDecoration: "none", - transition: "all 0.2s", + transition: "all 0.15s ease", })} > {link.label} diff --git a/web/src/components/audit/AuditDetailPanel.tsx b/web/src/components/audit/AuditDetailPanel.tsx index dc83757..ba5819a 100644 --- a/web/src/components/audit/AuditDetailPanel.tsx +++ b/web/src/components/audit/AuditDetailPanel.tsx @@ -252,7 +252,7 @@ export function AuditDetailPanel({ height: "100%", width: `${Math.min(audit.score * 100, 100)}%`, backgroundColor: color, - transition: "width 0.3s, background-color 0.3s", + transition: "all 0.15s ease", borderRadius: "0 3px 3px 0", }} /> diff --git a/web/src/components/audit/AuditSummaryBar.tsx b/web/src/components/audit/AuditSummaryBar.tsx index 93021d6..503037d 100644 --- a/web/src/components/audit/AuditSummaryBar.tsx +++ b/web/src/components/audit/AuditSummaryBar.tsx @@ -54,7 +54,7 @@ export function AuditSummaryBar({ fontSize: "0.7rem", fontWeight: 600, color: "var(--ctp-crust)", - transition: "opacity 0.2s", + transition: "all 0.15s ease", outline: isActive ? "2px solid var(--ctp-text)" : "none", outlineOffset: -2, }} @@ -75,12 +75,8 @@ export function AuditSummaryBar({ color: "var(--ctp-subtext0)", }} > - - {summary.total_items} items - - - Avg score: {(summary.avg_score * 100).toFixed(1)}% - + {summary.total_items} items + Avg score: {(summary.avg_score * 100).toFixed(1)}% {summary.manufactured_without_bom > 0 && ( {summary.manufactured_without_bom} manufactured without BOM diff --git a/web/src/components/audit/AuditTable.tsx b/web/src/components/audit/AuditTable.tsx index c471d9a..c71e095 100644 --- a/web/src/components/audit/AuditTable.tsx +++ b/web/src/components/audit/AuditTable.tsx @@ -85,9 +85,9 @@ export function AuditTable({ style={{ cursor: "pointer", backgroundColor: isSelected - ? "var(--ctp-surface1)" + ? "rgba(203, 166, 247, 0.08)" : "transparent", - transition: "background-color 0.15s", + transition: "all 0.15s ease", }} onMouseEnter={(e) => { if (!isSelected) @@ -154,7 +154,7 @@ const thStyle: React.CSSProperties = { padding: "var(--d-th-py) var(--d-th-px)", fontSize: "var(--d-th-font)", borderBottom: "1px solid var(--ctp-surface1)", - color: "var(--ctp-subtext0)", + color: "var(--ctp-overlay1)", fontWeight: 500, position: "sticky", top: 0, diff --git a/web/src/components/items/BOMTab.tsx b/web/src/components/items/BOMTab.tsx index b9f6352..0d1e64a 100644 --- a/web/src/components/items/BOMTab.tsx +++ b/web/src/components/items/BOMTab.tsx @@ -406,7 +406,7 @@ const thStyle: React.CSSProperties = { padding: "0.3rem 0.5rem", textAlign: "left", borderBottom: "1px solid var(--ctp-surface1)", - color: "var(--ctp-subtext1)", + color: "var(--ctp-overlay1)", fontWeight: 600, fontSize: "0.7rem", textTransform: "uppercase", @@ -422,9 +422,10 @@ const tdStyle: React.CSSProperties = { const toolBtnStyle: React.CSSProperties = { padding: "0.25rem 0.5rem", - fontSize: "0.8rem", + fontSize: "0.75rem", + fontWeight: 500, border: "none", - borderRadius: "0.3rem", + borderRadius: "0.375rem", backgroundColor: "var(--ctp-surface1)", color: "var(--ctp-text)", cursor: "pointer", @@ -436,14 +437,17 @@ const actionBtnStyle: React.CSSProperties = { color: "var(--ctp-subtext1)", cursor: "pointer", fontSize: "0.75rem", + fontWeight: 500, padding: "0.1rem 0.3rem", + borderRadius: "0.375rem", }; const saveBtnStyle: React.CSSProperties = { padding: "0.2rem 0.4rem", fontSize: "0.75rem", + fontWeight: 500, border: "none", - borderRadius: "0.25rem", + borderRadius: "0.375rem", backgroundColor: "var(--ctp-green)", color: "var(--ctp-crust)", cursor: "pointer", @@ -472,8 +476,9 @@ const manualBadge: React.CSSProperties = { const cancelBtnStyle: React.CSSProperties = { padding: "0.2rem 0.4rem", fontSize: "0.75rem", + fontWeight: 500, border: "none", - borderRadius: "0.25rem", + borderRadius: "0.375rem", backgroundColor: "var(--ctp-surface1)", color: "var(--ctp-subtext1)", cursor: "pointer", diff --git a/web/src/components/items/CategoryPicker.tsx b/web/src/components/items/CategoryPicker.tsx index 07b1910..4b64592 100644 --- a/web/src/components/items/CategoryPicker.tsx +++ b/web/src/components/items/CategoryPicker.tsx @@ -96,10 +96,10 @@ export function CategoryPicker({ }} style={{ padding: "0.2rem 0.5rem", - fontSize: "0.7rem", - fontWeight: isActive ? 600 : 400, + fontSize: "0.75rem", + fontWeight: 500, border: "none", - borderRadius: "0.25rem", + borderRadius: "0.375rem", cursor: "pointer", backgroundColor: isActive ? "rgba(203,166,247,0.2)" @@ -107,7 +107,7 @@ export function CategoryPicker({ color: isActive ? "var(--ctp-mauve)" : "var(--ctp-subtext0)", - transition: "background-color 0.1s", + transition: "all 0.15s ease", }} > @@ -188,7 +188,7 @@ export function CategoryPicker({ : "transparent", color: isSelected ? "var(--ctp-mauve)" : "var(--ctp-text)", fontWeight: isSelected ? 600 : 400, - transition: "background-color 0.1s", + transition: "all 0.15s ease", }} onMouseEnter={(e) => { if (!isSelected) diff --git a/web/src/components/items/CreateItemPane.tsx b/web/src/components/items/CreateItemPane.tsx index 226f74e..cac706f 100644 --- a/web/src/components/items/CreateItemPane.tsx +++ b/web/src/components/items/CreateItemPane.tsx @@ -453,6 +453,7 @@ function renderField(