Merge pull request 'fix: style guide batch 2 — buttons, tables, transitions, inputs, validation' (#84) from fix-style-guide-batch-2 into main
Reviewed-on: #84
This commit was merged in pull request #84.
This commit is contained in:
515
docs/STYLE.md
Normal file
515
docs/STYLE.md
Normal file
@@ -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;
|
||||
}
|
||||
```
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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)",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{summary.total_items} items
|
||||
</span>
|
||||
<span>
|
||||
Avg score: {(summary.avg_score * 100).toFixed(1)}%
|
||||
</span>
|
||||
<span>{summary.total_items} items</span>
|
||||
<span>Avg score: {(summary.avg_score * 100).toFixed(1)}%</span>
|
||||
{summary.manufactured_without_bom > 0 && (
|
||||
<span style={{ color: "var(--ctp-red)" }}>
|
||||
{summary.manufactured_without_bom} manufactured without BOM
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
@@ -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)
|
||||
|
||||
@@ -453,6 +453,7 @@ function renderField(
|
||||
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label={field.label}>
|
||||
<textarea
|
||||
className="silo-input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ ...inputStyle, minHeight: 60, resize: "vertical" }}
|
||||
@@ -467,6 +468,7 @@ function renderField(
|
||||
return (
|
||||
<FormGroup key={field.name} label={field.label}>
|
||||
<select
|
||||
className="silo-input"
|
||||
value={value || (field.default != null ? String(field.default) : "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
@@ -486,6 +488,7 @@ function renderField(
|
||||
return (
|
||||
<FormGroup key={field.name} label={field.label}>
|
||||
<select
|
||||
className="silo-input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
@@ -505,6 +508,7 @@ function renderField(
|
||||
label={`${field.label}${field.currency ? ` (${field.currency})` : ""}`}
|
||||
>
|
||||
<input
|
||||
className="silo-input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={value}
|
||||
@@ -521,6 +525,7 @@ function renderField(
|
||||
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label={field.label}>
|
||||
<input
|
||||
className="silo-input"
|
||||
type="url"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
@@ -541,6 +546,7 @@ function renderField(
|
||||
return (
|
||||
<FormGroup key={field.name} label={field.label}>
|
||||
<input
|
||||
className="silo-input"
|
||||
type={inputType}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
@@ -671,9 +677,10 @@ const headerStyle: React.CSSProperties = {
|
||||
|
||||
const actionBtnStyle: React.CSSProperties = {
|
||||
padding: "0.3rem 0.75rem",
|
||||
fontSize: "0.8rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
borderRadius: "0.375rem",
|
||||
color: "var(--ctp-crust)",
|
||||
cursor: "pointer",
|
||||
};
|
||||
@@ -683,8 +690,10 @@ const cancelBtnStyle: React.CSSProperties = {
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontSize: "0.8rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
padding: "0.2rem 0.4rem",
|
||||
borderRadius: "0.375rem",
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { del } from '../../api/client';
|
||||
import { useState } from "react";
|
||||
import { del } from "../../api/client";
|
||||
|
||||
interface DeleteItemPaneProps {
|
||||
partNumber: string;
|
||||
@@ -7,7 +7,11 @@ interface DeleteItemPaneProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DeleteItemPane({ partNumber, onDeleted, onCancel }: DeleteItemPaneProps) {
|
||||
export function DeleteItemPane({
|
||||
partNumber,
|
||||
onDeleted,
|
||||
onCancel,
|
||||
}: DeleteItemPaneProps) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -18,59 +22,133 @@ export function DeleteItemPane({ partNumber, onDeleted, onCancel }: DeleteItemPa
|
||||
await del(`/api/items/${encodeURIComponent(partNumber)}`);
|
||||
onDeleted();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to delete item');
|
||||
setError(e instanceof Error ? e.message : "Failed to delete item");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderBottom: '1px solid var(--ctp-surface1)',
|
||||
backgroundColor: 'var(--ctp-mantle)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ color: 'var(--ctp-red)', fontWeight: 600, fontSize: '0.9rem' }}>Delete Item</span>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
backgroundColor: "var(--ctp-mantle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ctp-red)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Delete Item
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button>
|
||||
<button onClick={onCancel} style={headerBtnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem', gap: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "2rem",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div style={{ color: 'var(--ctp-red)', backgroundColor: 'rgba(243,139,168,0.1)', padding: '0.5rem 1rem', borderRadius: '0.3rem', fontSize: '0.85rem', width: '100%', textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--ctp-red)",
|
||||
backgroundColor: "rgba(243,139,168,0.1)",
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "0.3rem",
|
||||
fontSize: "0.85rem",
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--ctp-text)', marginBottom: '0.5rem' }}>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "var(--ctp-text)",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
Permanently delete item
|
||||
</p>
|
||||
<p style={{ fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
color: "var(--ctp-peach)",
|
||||
fontSize: "1.1rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{partNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style={{ color: 'var(--ctp-subtext0)', fontSize: '0.85rem', textAlign: 'center', maxWidth: 300 }}>
|
||||
This will permanently remove this item, all its revisions, BOM entries, and file attachments. This action cannot be undone.
|
||||
<p
|
||||
style={{
|
||||
color: "var(--ctp-subtext0)",
|
||||
fontSize: "0.85rem",
|
||||
textAlign: "center",
|
||||
maxWidth: 300,
|
||||
}}
|
||||
>
|
||||
This will permanently remove this item, all its revisions, BOM
|
||||
entries, and file attachments. This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
|
||||
<button onClick={onCancel} style={{
|
||||
padding: '0.5rem 1.25rem', fontSize: '0.85rem', border: 'none', borderRadius: '0.4rem',
|
||||
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-text)', cursor: 'pointer',
|
||||
}}>
|
||||
<div style={{ display: "flex", gap: "0.75rem", marginTop: "0.5rem" }}>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: "0.5rem 1.25rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
border: "none",
|
||||
borderRadius: "0.375rem",
|
||||
backgroundColor: "var(--ctp-surface1)",
|
||||
color: "var(--ctp-text)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={() => void handleDelete()} disabled={deleting} style={{
|
||||
padding: '0.5rem 1.25rem', fontSize: '0.85rem', border: 'none', borderRadius: '0.4rem',
|
||||
backgroundColor: 'var(--ctp-red)', color: 'var(--ctp-crust)', cursor: 'pointer',
|
||||
opacity: deleting ? 0.6 : 1,
|
||||
}}>
|
||||
{deleting ? 'Deleting...' : 'Delete Permanently'}
|
||||
<button
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleting}
|
||||
style={{
|
||||
padding: "0.5rem 1.25rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
border: "none",
|
||||
borderRadius: "0.375rem",
|
||||
backgroundColor: "var(--ctp-red)",
|
||||
color: "var(--ctp-crust)",
|
||||
cursor: "pointer",
|
||||
opacity: deleting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete Permanently"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,6 +157,12 @@ export function DeleteItemPane({ partNumber, onDeleted, onCancel }: DeleteItemPa
|
||||
}
|
||||
|
||||
const headerBtnStyle: React.CSSProperties = {
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem',
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
padding: "0.2rem 0.4rem",
|
||||
borderRadius: "0.375rem",
|
||||
};
|
||||
|
||||
@@ -90,9 +90,10 @@ export function EditItemPane({
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: "0.3rem 0.75rem",
|
||||
fontSize: "0.8rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
borderRadius: "0.375rem",
|
||||
backgroundColor: "var(--ctp-blue)",
|
||||
color: "var(--ctp-crust)",
|
||||
cursor: "pointer",
|
||||
@@ -124,6 +125,7 @@ export function EditItemPane({
|
||||
|
||||
<FormGroup label="Part Number">
|
||||
<input
|
||||
className="silo-input"
|
||||
value={pn}
|
||||
onChange={(e) => setPN(e.target.value)}
|
||||
style={inputStyle}
|
||||
@@ -132,6 +134,7 @@ export function EditItemPane({
|
||||
|
||||
<FormGroup label="Type">
|
||||
<select
|
||||
className="silo-input"
|
||||
value={itemType}
|
||||
onChange={(e) => setItemType(e.target.value)}
|
||||
style={inputStyle}
|
||||
@@ -145,6 +148,7 @@ export function EditItemPane({
|
||||
|
||||
<FormGroup label="Description">
|
||||
<input
|
||||
className="silo-input"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
style={inputStyle}
|
||||
@@ -153,6 +157,7 @@ export function EditItemPane({
|
||||
|
||||
<FormGroup label="Sourcing Type">
|
||||
<select
|
||||
className="silo-input"
|
||||
value={sourcingType}
|
||||
onChange={(e) => setSourcingType(e.target.value)}
|
||||
style={inputStyle}
|
||||
@@ -166,6 +171,7 @@ export function EditItemPane({
|
||||
|
||||
<FormGroup label="Long Description">
|
||||
<textarea
|
||||
className="silo-input"
|
||||
value={longDescription}
|
||||
onChange={(e) => setLongDescription(e.target.value)}
|
||||
style={{ ...inputStyle, minHeight: 80, resize: "vertical" }}
|
||||
@@ -215,6 +221,8 @@ const headerBtnStyle: React.CSSProperties = {
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontSize: "0.8rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
padding: "0.2rem 0.4rem",
|
||||
borderRadius: "0.375rem",
|
||||
};
|
||||
|
||||
@@ -72,10 +72,8 @@ export function FileDropZone({
|
||||
padding: "1.25rem",
|
||||
textAlign: "center",
|
||||
cursor: "pointer",
|
||||
backgroundColor: dragOver
|
||||
? "rgba(203,166,247,0.05)"
|
||||
: "transparent",
|
||||
transition: "border-color 0.15s, background-color 0.15s",
|
||||
backgroundColor: dragOver ? "rgba(203,166,247,0.05)" : "transparent",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "0.85rem", color: "var(--ctp-subtext1)" }}>
|
||||
@@ -113,7 +111,11 @@ export function FileDropZone({
|
||||
{files.length > 0 && (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
{files.map((att, i) => (
|
||||
<FileRow key={i} attachment={att} onRemove={() => onFileRemoved(i)} />
|
||||
<FileRow
|
||||
key={i}
|
||||
attachment={att}
|
||||
onRemove={() => onFileRemoved(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -202,7 +204,7 @@ function FileRow({
|
||||
width: `${attachment.uploadProgress}%`,
|
||||
backgroundColor: "var(--ctp-mauve)",
|
||||
borderRadius: 1,
|
||||
transition: "width 0.15s",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -235,7 +237,7 @@ function FileRow({
|
||||
color: hovered ? "var(--ctp-red)" : "var(--ctp-overlay0)",
|
||||
padding: "0 0.2rem",
|
||||
flexShrink: 0,
|
||||
transition: "color 0.15s",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
|
||||
@@ -185,9 +185,10 @@ export function ImportItemsPane({
|
||||
disabled={!file || importing}
|
||||
style={{
|
||||
padding: "0.4rem 0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
borderRadius: "0.375rem",
|
||||
backgroundColor: "var(--ctp-yellow)",
|
||||
color: "var(--ctp-crust)",
|
||||
cursor: "pointer",
|
||||
@@ -202,9 +203,10 @@ export function ImportItemsPane({
|
||||
disabled={importing || (result?.error_count ?? 0) > 0}
|
||||
style={{
|
||||
padding: "0.4rem 0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
borderRadius: "0.375rem",
|
||||
backgroundColor: "var(--ctp-green)",
|
||||
color: "var(--ctp-crust)",
|
||||
cursor: "pointer",
|
||||
@@ -289,6 +291,8 @@ const headerBtnStyle: React.CSSProperties = {
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontSize: "0.8rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
padding: "0.2rem 0.4rem",
|
||||
borderRadius: "0.375rem",
|
||||
};
|
||||
|
||||
@@ -150,7 +150,7 @@ export function ItemTable({
|
||||
padding: "var(--d-th-py) var(--d-th-px)",
|
||||
textAlign: "left",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
color: "var(--ctp-subtext1)",
|
||||
color: "var(--ctp-overlay1)",
|
||||
fontWeight: 600,
|
||||
fontSize: "var(--d-th-font)",
|
||||
textTransform: "uppercase",
|
||||
@@ -203,7 +203,7 @@ export function ItemTable({
|
||||
{sortedItems.map((item, idx) => {
|
||||
const isSelected = item.part_number === selectedPN;
|
||||
const rowBg = isSelected
|
||||
? "var(--ctp-surface1)"
|
||||
? "rgba(203, 166, 247, 0.08)"
|
||||
: idx % 2 === 0
|
||||
? "var(--ctp-base)"
|
||||
: "var(--ctp-surface0)";
|
||||
@@ -385,7 +385,8 @@ const actionBtnStyle: React.CSSProperties = {
|
||||
border: "none",
|
||||
color: "var(--ctp-subtext1)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
padding: "0.15rem 0.4rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: "0.375rem",
|
||||
};
|
||||
|
||||
@@ -37,9 +37,10 @@ export function ItemsToolbar({
|
||||
onClick={() => onFilterChange({ searchScope: scope })}
|
||||
style={{
|
||||
padding: "var(--d-input-py) var(--d-input-px)",
|
||||
fontSize: "var(--d-input-font)",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
borderRadius: "0.375rem",
|
||||
cursor: "pointer",
|
||||
backgroundColor:
|
||||
filters.searchScope === scope
|
||||
@@ -173,8 +174,9 @@ const toolBtnStyle: React.CSSProperties = {
|
||||
padding: "var(--d-input-py) var(--d-input-px)",
|
||||
backgroundColor: "var(--ctp-surface1)",
|
||||
border: "none",
|
||||
borderRadius: "0.4rem",
|
||||
borderRadius: "0.375rem",
|
||||
color: "var(--ctp-text)",
|
||||
fontSize: "var(--d-input-font)",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ export function LoginPage() {
|
||||
<div style={formGroupStyle}>
|
||||
<label style={labelStyle}>Username</label>
|
||||
<input
|
||||
className="silo-input"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -55,6 +56,7 @@ export function LoginPage() {
|
||||
<div style={formGroupStyle}>
|
||||
<label style={labelStyle}>Password</label>
|
||||
<input
|
||||
className="silo-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -162,9 +164,9 @@ const btnPrimaryStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "0.75rem 1.5rem",
|
||||
borderRadius: "0.5rem",
|
||||
fontWeight: 600,
|
||||
fontSize: "1rem",
|
||||
borderRadius: "0.375rem",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-mauve)",
|
||||
@@ -187,9 +189,9 @@ const btnOidcStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "0.75rem 1.5rem",
|
||||
borderRadius: "0.5rem",
|
||||
fontWeight: 600,
|
||||
fontSize: "1rem",
|
||||
borderRadius: "0.375rem",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-blue)",
|
||||
|
||||
@@ -235,6 +235,7 @@ export function ProjectsPage() {
|
||||
Code (2-10 characters, uppercase)
|
||||
</label>
|
||||
<input
|
||||
className="silo-input"
|
||||
type="text"
|
||||
value={formCode}
|
||||
onChange={(e) => setFormCode(e.target.value)}
|
||||
@@ -249,6 +250,7 @@ export function ProjectsPage() {
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Name</label>
|
||||
<input
|
||||
className="silo-input"
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
@@ -259,6 +261,7 @@ export function ProjectsPage() {
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Description</label>
|
||||
<input
|
||||
className="silo-input"
|
||||
type="text"
|
||||
value={formDesc}
|
||||
onChange={(e) => setFormDesc(e.target.value)}
|
||||
@@ -443,43 +446,45 @@ export function ProjectsPage() {
|
||||
// Styles
|
||||
const btnPrimaryStyle: React.CSSProperties = {
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "0.4rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-mauve)",
|
||||
color: "var(--ctp-crust)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const btnSecondaryStyle: React.CSSProperties = {
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "0.4rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-surface1)",
|
||||
color: "var(--ctp-text)",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const btnDangerStyle: React.CSSProperties = {
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "0.4rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-red)",
|
||||
color: "var(--ctp-crust)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const btnSmallStyle: React.CSSProperties = {
|
||||
padding: "0.3rem 0.6rem",
|
||||
borderRadius: "0.3rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-surface1)",
|
||||
color: "var(--ctp-text)",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
@@ -504,8 +509,9 @@ const formCloseStyle: React.CSSProperties = {
|
||||
border: "none",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
borderRadius: "0.375rem",
|
||||
};
|
||||
|
||||
const errorBannerStyle: React.CSSProperties = {
|
||||
@@ -552,7 +558,7 @@ const thStyle: React.CSSProperties = {
|
||||
padding: "0.5rem 0.75rem",
|
||||
textAlign: "left",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
color: "var(--ctp-subtext1)",
|
||||
color: "var(--ctp-overlay1)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8rem",
|
||||
textTransform: "uppercase",
|
||||
|
||||
@@ -436,6 +436,7 @@ function SegmentBlock({
|
||||
})
|
||||
}
|
||||
required
|
||||
className="silo-input"
|
||||
style={inlineInputStyle}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -573,6 +574,7 @@ function SegmentBlock({
|
||||
}
|
||||
placeholder="Code"
|
||||
required
|
||||
className="silo-input"
|
||||
style={inlineInputStyle}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -597,6 +599,7 @@ function SegmentBlock({
|
||||
}
|
||||
placeholder="Description"
|
||||
required
|
||||
className="silo-input"
|
||||
style={inlineInputStyle}
|
||||
/>
|
||||
<button
|
||||
@@ -691,7 +694,7 @@ const thStyle: React.CSSProperties = {
|
||||
padding: "0.4rem 0.75rem",
|
||||
textAlign: "left",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
color: "var(--ctp-subtext1)",
|
||||
color: "var(--ctp-overlay1)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8rem",
|
||||
textTransform: "uppercase",
|
||||
@@ -706,22 +709,23 @@ const tdStyle: React.CSSProperties = {
|
||||
|
||||
const btnTinyStyle: React.CSSProperties = {
|
||||
padding: "0.2rem 0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-surface1)",
|
||||
color: "var(--ctp-text)",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const btnTinyPrimaryStyle: React.CSSProperties = {
|
||||
padding: "0.2rem 0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-mauve)",
|
||||
color: "var(--ctp-crust)",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ export function SettingsPage() {
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
placeholder="e.g., FreeCAD workstation"
|
||||
required
|
||||
className="silo-input"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
@@ -404,12 +405,12 @@ const inputStyle: React.CSSProperties = {
|
||||
|
||||
const btnPrimaryStyle: React.CSSProperties = {
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "0.4rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-mauve)",
|
||||
color: "var(--ctp-crust)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
@@ -418,19 +419,22 @@ const btnCopyStyle: React.CSSProperties = {
|
||||
padding: "0.4rem 0.75rem",
|
||||
background: "var(--ctp-surface1)",
|
||||
border: "none",
|
||||
borderRadius: "0.4rem",
|
||||
borderRadius: "0.375rem",
|
||||
color: "var(--ctp-text)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const btnDismissStyle: React.CSSProperties = {
|
||||
padding: "0.4rem 0.75rem",
|
||||
background: "none",
|
||||
border: "none",
|
||||
borderRadius: "0.375rem",
|
||||
color: "var(--ctp-subtext0)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const btnDangerStyle: React.CSSProperties = {
|
||||
@@ -438,9 +442,10 @@ const btnDangerStyle: React.CSSProperties = {
|
||||
color: "var(--ctp-red)",
|
||||
border: "none",
|
||||
padding: "0.3rem 0.6rem",
|
||||
borderRadius: "0.3rem",
|
||||
borderRadius: "0.375rem",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const btnRevokeConfirmStyle: React.CSSProperties = {
|
||||
@@ -448,19 +453,20 @@ const btnRevokeConfirmStyle: React.CSSProperties = {
|
||||
color: "var(--ctp-crust)",
|
||||
border: "none",
|
||||
padding: "0.2rem 0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: "0.375rem",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const btnTinyStyle: React.CSSProperties = {
|
||||
padding: "0.2rem 0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-surface1)",
|
||||
color: "var(--ctp-text)",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
@@ -474,7 +480,7 @@ const thStyle: React.CSSProperties = {
|
||||
padding: "0.5rem 0.75rem",
|
||||
textAlign: "left",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
color: "var(--ctp-subtext1)",
|
||||
color: "var(--ctp-overlay1)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8rem",
|
||||
textTransform: "uppercase",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "./theme.css";
|
||||
@import "./silo-base.css";
|
||||
|
||||
*,
|
||||
*::before,
|
||||
|
||||
14
web/src/styles/silo-base.css
Normal file
14
web/src/styles/silo-base.css
Normal file
@@ -0,0 +1,14 @@
|
||||
/* Focus and hover states for form inputs */
|
||||
.silo-input {
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.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);
|
||||
outline: none;
|
||||
}
|
||||
Reference in New Issue
Block a user